mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-09 03:07:46 +08:00
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.
5830 lines
237 KiB
TypeScript
5830 lines
237 KiB
TypeScript
import type { Locator, Page, Route } from "@playwright/test"
|
|
import { FLOWCHART_XML } from "./fixtures/diagrams"
|
|
import {
|
|
expect,
|
|
getAttachmentInput,
|
|
getChatInput,
|
|
getIframe,
|
|
getIframeContent,
|
|
getSendButton,
|
|
openDrawioFile,
|
|
openSettingsTab,
|
|
sendMessage,
|
|
test,
|
|
waitForComplete,
|
|
waitForCompleteCount,
|
|
} from "./lib/fixtures"
|
|
import { createMockSSEResponse } 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,
|
|
},
|
|
)
|
|
}
|
|
|
|
// A page with a name and a background, and no shapes
|
|
const BLANK_SHEET =
|
|
'<mxfile><diagram name="Sheet" id="s"><mxGraphModel background="#ffeecc"><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>'
|
|
|
|
const TWO_PAGE_FILE = `<mxfile>${["Old A", "Old B"]
|
|
.map(
|
|
(label, i) =>
|
|
`<diagram name="P${i}" id="p${i}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="c${i}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram>`,
|
|
)
|
|
.join("")}</mxfile>`
|
|
|
|
/** When each saved chat was last saved, by title */
|
|
function sessionTimes(page: Page) {
|
|
return page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const sessions: any[] = await new Promise((resolve) => {
|
|
const request = db
|
|
.transaction("sessions")
|
|
.objectStore("sessions")
|
|
.getAll()
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
db.close()
|
|
return Object.fromEntries(sessions.map((s) => [s.title, s.updatedAt]))
|
|
})
|
|
}
|
|
|
|
/** Where an element is laid out (fails the test when it is not) */
|
|
async function boxOf(locator: Locator) {
|
|
const box = await locator.boundingBox()
|
|
if (!box) throw new Error("element is not laid out")
|
|
return box
|
|
}
|
|
|
|
/** draw.io's own Undo button: draw.io sets "disabled" with nothing to undo */
|
|
function drawioUndo(page: Page) {
|
|
return getIframeContent(page).locator('.geSimpleMainMenu a[title="Undo"]')
|
|
}
|
|
|
|
/** draw.io's own page tabs */
|
|
function drawioTabs(page: Page) {
|
|
return getIframeContent(page).locator(".geTabContainer")
|
|
}
|
|
|
|
/** How wide the paper shows on screen, which changes with the zoom */
|
|
function paperWidth(page: Page) {
|
|
return getIframeContent(page)
|
|
.locator(".geBackgroundPage")
|
|
.first()
|
|
.evaluate((el) => Math.round(el.getBoundingClientRect().width))
|
|
}
|
|
|
|
/** Zooms with draw.io's own shortcut, the focus in an empty canvas corner */
|
|
async function zoomCanvas(page: Page, direction: "in" | "out") {
|
|
await getIframeContent(page)
|
|
.locator(".geDiagramContainer")
|
|
.click({ position: { x: 20, y: 20 } })
|
|
await page.keyboard.press(
|
|
direction === "in" ? "ControlOrMeta+Equal" : "ControlOrMeta+Minus",
|
|
)
|
|
}
|
|
|
|
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; the latest undoes and redoes it", 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")
|
|
// Only the added shape counts: the others kept their XML as saved
|
|
await expect(cards.nth(1)).toContainText("Added 1 shape")
|
|
await expect(cards.nth(1)).not.toContainText("changed")
|
|
await expect(page.locator('[data-testid="version-thumb"]')).toHaveCount(
|
|
2,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
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("an older version card still shows and copies the AI's XML", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await drawAndEdit(page)
|
|
const older = page.locator('[data-testid="version-card"]').first()
|
|
await older.getByRole("button", { name: "Show XML" }).click()
|
|
await expect(older).toContainText('value="Start"')
|
|
await expect(
|
|
older.getByRole("button", { name: "Copy response" }),
|
|
).toBeVisible()
|
|
await older.getByRole("button", { name: "Hide XML" }).click()
|
|
await expect(older).not.toContainText('value="Start"')
|
|
})
|
|
|
|
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("a file with a comment or DOCTYPE before its root opens", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"commented.drawio",
|
|
`<?xml version="1.0" encoding="UTF-8"?>\n<!-- Architecture, v2 -->\n<!DOCTYPE mxfile>\n<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="Commented" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
await expect(
|
|
getIframeContent(page).getByText("Commented"),
|
|
).toBeVisible()
|
|
})
|
|
|
|
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 openDrawioFile(page, "mine.drawio", 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()
|
|
// ⌘⇧B outside the canvas stays with the browser too
|
|
await page.locator('[data-testid="session-title"]').focus()
|
|
await page.keyboard.press("ControlOrMeta+Shift+B")
|
|
await page.waitForTimeout(500)
|
|
await expect(page.locator('[data-testid="chat-panel"]')).toBeVisible()
|
|
})
|
|
|
|
test("selected shapes are shown, sent, and attached again when picked 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 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" },
|
|
])
|
|
// 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)
|
|
const initial = await paperWidth(page)
|
|
await zoomCanvas(page, "out")
|
|
await expect.poll(() => paperWidth(page)).not.toBe(initial)
|
|
const before = await paperWidth(page)
|
|
await page.locator('[data-testid="version-restore"]').first().click()
|
|
await page.waitForTimeout(800)
|
|
// (A zoom step changes it by about 15%)
|
|
expect(Math.abs((await paperWidth(page)) - before)).toBeLessThan(3)
|
|
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("draw.io's shape library starts closed", async ({ page }) => {
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
await page.waitForTimeout(800)
|
|
const library = getIframeContent(page).locator(
|
|
".geSidebarContainer:not(.geFormatContainer)",
|
|
)
|
|
expect(
|
|
await library.evaluate((el) => (el as HTMLElement).clientWidth),
|
|
).toBe(0)
|
|
})
|
|
|
|
test("draw.io's Insert menu offers auto layout, Mermaid and CSV import", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
const canvas = getIframeContent(page)
|
|
await canvas.locator('.geSimpleMainMenu a[title="Insert"]').click()
|
|
const menus = canvas.locator(".mxPopupMenu")
|
|
await expect(
|
|
menus.first().getByText("Mermaid...", { exact: true }),
|
|
).toBeVisible()
|
|
await menus.first().getByText("Layout", { exact: true }).click()
|
|
await expect(
|
|
menus.last().getByText("Vertical Tree...", { exact: true }),
|
|
).toBeVisible()
|
|
await menus.first().getByText("Advanced", { exact: true }).click()
|
|
await expect(
|
|
menus.last().getByText("CSV...", { exact: true }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("draw.io follows the app's dark mode, switched without a reload", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("next-ai-draw-io-dark-mode", "true")
|
|
})
|
|
await openApp(page)
|
|
// A mark on draw.io's window: a reload would make a new window
|
|
const drawio = (mark = false) =>
|
|
getIframe(page).evaluate((el, mark) => {
|
|
const w = (el as HTMLIFrameElement).contentWindow as any
|
|
if (mark) w.__naiMark = 1
|
|
return { dark: w.Editor.isDarkMode(), mark: w.__naiMark }
|
|
}, mark)
|
|
expect((await drawio(true)).dark).toBe(true)
|
|
await openSettingsTab(page, "appearance")
|
|
const dialog = page.getByRole("dialog")
|
|
await dialog.getByRole("radio", { name: "Light" }).click()
|
|
await expect.poll(() => drawio()).toEqual({ dark: false, mark: 1 })
|
|
await dialog.getByRole("radio", { name: "Dark" }).click()
|
|
await expect.poll(() => drawio()).toEqual({ dark: true, mark: 1 })
|
|
})
|
|
|
|
test("the table around the sheet follows a theme switched either way", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
if (sessionStorage.getItem("nai-seeded")) return
|
|
sessionStorage.setItem("nai-seeded", "1")
|
|
localStorage.setItem("next-ai-draw-io-dark-mode", "false")
|
|
})
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
const table = () =>
|
|
getIframeContent(page)
|
|
.locator(".geDiagramContainer")
|
|
.evaluate((el) => getComputedStyle(el).backgroundColor)
|
|
const light = await table()
|
|
await openSettingsTab(page, "appearance")
|
|
const dialog = page.getByRole("dialog")
|
|
await dialog.getByRole("radio", { name: "Dark" }).click()
|
|
// draw.io's own dark table
|
|
await expect.poll(table).toBe("rgb(27, 29, 30)")
|
|
await dialog.getByRole("radio", { name: "Light" }).click()
|
|
await expect.poll(table).toBe(light)
|
|
await page.keyboard.press("Escape")
|
|
// Started dark, switched to light
|
|
await page.evaluate(() =>
|
|
localStorage.setItem("next-ai-draw-io-dark-mode", "true"),
|
|
)
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await expect(
|
|
page.locator('[data-testid="canvas-loading"]'),
|
|
).toHaveCount(0, { timeout: 30000 })
|
|
await expect.poll(table).toBe("rgb(27, 29, 30)")
|
|
await openSettingsTab(page, "appearance")
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("radio", { name: "Light" })
|
|
.click()
|
|
await expect.poll(table).toBe(light)
|
|
})
|
|
|
|
test("selecting shapes adds no more listeners to draw.io", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
// The editor, from the next call of one of its methods
|
|
await getIframe(page).evaluate((el) => {
|
|
const w = (el as HTMLIFrameElement).contentWindow as any
|
|
const proto = w.EditorUi.prototype
|
|
const original = proto.updateActionStates
|
|
proto.updateActionStates = function (...args: unknown[]) {
|
|
w.__ui = this
|
|
return original.apply(this, args)
|
|
}
|
|
})
|
|
const canvas = getIframeContent(page)
|
|
// draw.io's own parts listen too
|
|
const count = () =>
|
|
getIframe(page).evaluate((el) => {
|
|
const w = (el as HTMLIFrameElement).contentWindow as any
|
|
return (w.__ui.eventListeners as unknown[]).filter(
|
|
(item) => item === "darkModeChanged",
|
|
).length
|
|
})
|
|
await canvas.getByText("Start", { exact: true }).click()
|
|
const before = await count()
|
|
for (const label of ["Process", "End", "Process", "Start"]) {
|
|
await canvas.getByText(label, { exact: true }).click()
|
|
}
|
|
expect(await count()).toBe(before)
|
|
})
|
|
|
|
test("draw.io keeps the app's theme when the system's changes", async ({
|
|
page,
|
|
}) => {
|
|
await page.emulateMedia({ colorScheme: "light" })
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("next-ai-draw-io-dark-mode", "false")
|
|
})
|
|
await openApp(page)
|
|
const drawioDark = () =>
|
|
getIframe(page).evaluate((el) =>
|
|
(
|
|
(el as HTMLIFrameElement).contentWindow as any
|
|
).Editor.isDarkMode(),
|
|
)
|
|
expect(await drawioDark()).toBe(false)
|
|
await page.emulateMedia({ colorScheme: "dark" })
|
|
await page.waitForTimeout(1000)
|
|
expect(await drawioDark()).toBe(false)
|
|
await expect(page.locator("html")).not.toHaveClass(/dark/)
|
|
})
|
|
|
|
test("a reloaded chat shows its paper in the middle of the canvas", async ({
|
|
page,
|
|
}) => {
|
|
// Wide enough for draw.io to open its shape library on start
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
await page.waitForTimeout(1500)
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await expect(
|
|
page.locator('[data-testid="canvas-loading"]'),
|
|
).toHaveCount(0, { timeout: 30000 })
|
|
await expect(
|
|
getIframeContent(page).getByText("Process", { exact: true }),
|
|
).toBeVisible()
|
|
await page.waitForTimeout(1000)
|
|
const stage = await boxOf(page.locator('[data-testid="canvas-stage"]'))
|
|
const paper = await boxOf(
|
|
getIframeContent(page).locator(".geBackgroundPage").first(),
|
|
)
|
|
expect(
|
|
Math.abs(paper.x + paper.width / 2 - (stage.x + stage.width / 2)),
|
|
).toBeLessThan(20)
|
|
})
|
|
|
|
test("the ask AI button stays off draw.io's menus and dialogs", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
const canvas = getIframeContent(page)
|
|
await canvas.getByText("Process", { exact: true }).click()
|
|
const ask = page.locator('[data-testid="selection-ask"]')
|
|
await expect(ask).toBeVisible()
|
|
await canvas.locator('.geSimpleMainMenu a[title="Insert"]').click()
|
|
await expect(canvas.locator(".mxPopupMenu").first()).toBeVisible()
|
|
await expect(ask).toBeHidden()
|
|
// A click on the shape closes the menu and keeps it selected
|
|
await canvas.getByText("Process", { exact: true }).click()
|
|
await expect(canvas.locator(".mxPopupMenu")).toHaveCount(0)
|
|
await expect(ask).toBeVisible()
|
|
// The shape picker next to the selection (its arrows open it)
|
|
await getIframe(page).evaluate((el) => {
|
|
const w = (el as HTMLIFrameElement).contentWindow as any
|
|
const proto = w.EditorUi.prototype
|
|
const original = proto.updateActionStates
|
|
proto.updateActionStates = function (...args: unknown[]) {
|
|
w.__ui = this
|
|
return original.apply(this, args)
|
|
}
|
|
})
|
|
await canvas.getByText("Start", { exact: true }).click()
|
|
await canvas.getByText("Process", { exact: true }).click()
|
|
await expect(ask).toBeVisible()
|
|
await getIframe(page).evaluate((el) => {
|
|
const w = (el as HTMLIFrameElement).contentWindow as any
|
|
w.__ui.showShapePicker(300, 200)
|
|
})
|
|
await expect(canvas.locator(".geShapePicker")).toBeVisible()
|
|
await expect(ask).toBeHidden()
|
|
await getIframe(page).evaluate((el) => {
|
|
const w = (el as HTMLIFrameElement).contentWindow as any
|
|
w.__ui.hideShapePicker()
|
|
})
|
|
await expect(ask).toBeVisible()
|
|
// A dialog: Edit Style
|
|
await page.keyboard.press("ControlOrMeta+e")
|
|
await expect(canvas.locator(".geDialog").first()).toBeVisible()
|
|
await expect(ask).toBeHidden()
|
|
})
|
|
|
|
test("with the chat panel hidden, draw.io's right-hand buttons can be clicked", async ({
|
|
page,
|
|
}) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await page.getByRole("button", { name: "Hide chat panel" }).click()
|
|
const show = page.locator('[data-testid="show-panel"]')
|
|
await expect(show).toBeVisible()
|
|
await page.waitForTimeout(600)
|
|
const canvas = getIframeContent(page)
|
|
const chip = await boxOf(show.locator(".."))
|
|
for (const title of [
|
|
"Diagram",
|
|
"Format (⌘+⇧+P)",
|
|
"Format (Ctrl+Shift+P)",
|
|
]) {
|
|
const button = canvas.locator(
|
|
`.geSimpleMainMenu a[title="${title}"]`,
|
|
)
|
|
if ((await button.count()) === 0) continue
|
|
const box = await boxOf(button)
|
|
const overlaps =
|
|
box.x < chip.x + chip.width &&
|
|
chip.x < box.x + box.width &&
|
|
box.y < chip.y + chip.height &&
|
|
chip.y < box.y + box.height
|
|
expect(overlaps, title).toBe(false)
|
|
}
|
|
await canvas
|
|
.locator('.geSimpleMainMenu a[title="Diagram"]')
|
|
.click({ timeout: 3000 })
|
|
await expect(canvas.locator(".mxPopupMenu").first()).toBeVisible()
|
|
})
|
|
|
|
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(drawioUndo(page)).not.toHaveAttribute("disabled")
|
|
})
|
|
|
|
test("Ctrl+Z in the canvas undoes the AI change in one step; the card follows undo and redo", 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)
|
|
await expect(
|
|
canvas.getByText("Process", { exact: true }).first(),
|
|
).toBeVisible()
|
|
// The card says v2's change is undone, as its own Undo would
|
|
const button = page
|
|
.locator('[data-testid="version-card"]')
|
|
.nth(1)
|
|
.locator('[data-testid="version-undo"]')
|
|
await expect(button).toHaveText("Redo change")
|
|
// Redo in the canvas puts v2 back on it
|
|
await page.keyboard.press("ControlOrMeta+Shift+z")
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
await expect(button).toHaveText("Undo change")
|
|
await page.keyboard.press("ControlOrMeta+z")
|
|
await expect(button).toHaveText("Redo change")
|
|
await button.click()
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
await expect(button).toHaveText("Undo change")
|
|
})
|
|
|
|
test("past 20 versions the numbers stay; a compare open on the dropped one lets go", async ({
|
|
page,
|
|
}) => {
|
|
const boxFile = (label: string) =>
|
|
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="b" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="400" y="300" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
|
await page.route("**/api/chat", async (route) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(FLOWCHART_XML, "Flowchart."),
|
|
})
|
|
})
|
|
await openApp(page)
|
|
// A chat that already has 20 versions
|
|
await page.evaluate(
|
|
async ({ versions, diagramXml }) => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const now = Date.now()
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
tx.objectStore("sessions").put({
|
|
id: "many",
|
|
title: "Many versions",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
messages: [
|
|
{
|
|
id: "u1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Draw boxes" }],
|
|
},
|
|
{
|
|
id: "a1",
|
|
role: "assistant",
|
|
parts: [{ type: "text", text: "Boxes." }],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml,
|
|
versions,
|
|
})
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
},
|
|
{
|
|
versions: Array.from({ length: 20 }, (_, i) => ({
|
|
id: `v${i + 1}`,
|
|
number: i + 1,
|
|
xml: boxFile(`Box ${i + 1}`),
|
|
svg: "",
|
|
turnIndex: 0,
|
|
summary: {
|
|
shapesAdded: 0,
|
|
shapesRemoved: 0,
|
|
shapesChanged: 1,
|
|
edgesAdded: 0,
|
|
edgesRemoved: 0,
|
|
edgesChanged: 0,
|
|
},
|
|
})),
|
|
diagramXml: boxFile("Box 20"),
|
|
},
|
|
)
|
|
await page.goto("/?session=many", { waitUntil: "networkidle" })
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Box 20")).toBeVisible({ timeout: 30000 })
|
|
const thumbs = page.locator('[data-testid="version-thumb"]')
|
|
await expect(thumbs).toHaveCount(20)
|
|
|
|
// Compare v1 while the next answer is on its way
|
|
await sendMessage(page, "Draw a flowchart")
|
|
await thumbs.first().click()
|
|
await expect(page.getByRole("dialog")).toBeVisible()
|
|
await waitForComplete(page)
|
|
await expect(page.getByRole("dialog")).toHaveCount(0)
|
|
await expect(thumbs.first()).toContainText("v2")
|
|
await expect(thumbs.last()).toContainText("v21")
|
|
await expect(
|
|
page.locator('[data-testid="version-card"]').last(),
|
|
).toContainText("v21")
|
|
|
|
// The first Escape in the canvas is draw.io's again: it ends editing
|
|
await canvas.getByText("Start", { exact: true }).dblclick()
|
|
await expect(canvas.locator(".mxCellEditor")).toBeVisible()
|
|
await page.keyboard.press("Escape")
|
|
await expect(canvas.locator(".mxCellEditor:visible")).toHaveCount(0)
|
|
})
|
|
|
|
test("a drawing cut off by a dropped connection shows as failed, with Retry", async ({
|
|
page,
|
|
}) => {
|
|
// The stream ends in the middle of the tool call
|
|
const events = [
|
|
{ type: "start", messageId: "m1" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d1",
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-delta",
|
|
toolCallId: "d1",
|
|
inputTextDelta: '{"xml":"<mxCell id=\\"a\\"',
|
|
},
|
|
]
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: events
|
|
.map((e) => `data: ${JSON.stringify(e)}\n\n`)
|
|
.join(""),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "Draw")
|
|
await expect(getSendButton(page)).toBeVisible({ timeout: 15000 })
|
|
// Not "Stopped": the user did not stop it
|
|
await expect(
|
|
page.getByText("This attempt failed", { exact: true }),
|
|
).toBeVisible()
|
|
await expect(page.getByText("Stopped", { exact: true })).toHaveCount(0)
|
|
await expect(page.getByText("Drawing the diagram…")).toHaveCount(0)
|
|
await expect(
|
|
page.getByText("The answer ended before the diagram was finished."),
|
|
).toBeVisible()
|
|
await expect(page.locator('[data-testid="retry-button"]')).toBeVisible()
|
|
})
|
|
|
|
test("a short window keeps the send button on screen with a long message", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await page.setViewportSize({ width: 1280, height: 320 })
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await getChatInput(page).fill(
|
|
Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n"),
|
|
)
|
|
const send = await boxOf(getSendButton(page))
|
|
expect(send.y + send.height).toBeLessThanOrEqual(320)
|
|
})
|
|
|
|
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()
|
|
// Once fonts and the dialog's opening are done
|
|
await expect
|
|
.poll(async () =>
|
|
Math.abs((await boxOf(models)).y - (await boxOf(appearance)).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("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()
|
|
// Safari ends the composition first, then sends keyCode 229
|
|
await input.dispatchEvent("keydown", { key: "Enter", keyCode: 229 })
|
|
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)
|
|
})
|
|
|
|
test("a multi-page diagram's AI change can be undone from its card", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await openDrawioFile(page, "two.drawio", TWO_PAGE_FILE)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Old A")).toBeVisible()
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
// Multi-page documents are loaded in full: the card is the way back
|
|
await page.waitForTimeout(2000)
|
|
await page.locator('[data-testid="version-undo"]').click()
|
|
await expect(canvas.getByText("Old A")).toBeVisible()
|
|
await expect(page.getByText("Change undone")).toBeVisible()
|
|
})
|
|
|
|
test("reopening a chat keeps its card's undo and does not save it again", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
await page.waitForTimeout(2000)
|
|
const updatedAt = () => sessionTimes(page)
|
|
// Leaving the page saves the chat; opening it must not save again
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await expect(page.locator('[data-testid="version-undo"]')).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
const opened = await updatedAt()
|
|
await page.waitForTimeout(3000)
|
|
await expect(page.locator('[data-testid="version-undo"]')).toBeVisible()
|
|
expect(await updatedAt()).toEqual(opened)
|
|
})
|
|
|
|
test("restoring from the compare dialog; a new answer takes its Undo away", 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)
|
|
const toast = page.getByText("Restored v1")
|
|
await expect(toast).toBeVisible()
|
|
await sendMessage(page, "Add a review step")
|
|
await expect(toast).toHaveCount(0)
|
|
})
|
|
|
|
test("a broken file leaves the chat on screen", async ({ page }) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"cut.drawio",
|
|
'<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="Half" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>',
|
|
)
|
|
await expect(
|
|
page.getByText("cut.drawio isn't a draw.io diagram."),
|
|
).toBeVisible()
|
|
// A page that is neither a model nor draw.io's compressed form
|
|
await openDrawioFile(
|
|
page,
|
|
"packed.drawio",
|
|
'<mxfile><diagram id="p1" name="Broken">not-base64!</diagram></mxfile>',
|
|
)
|
|
await expect(
|
|
page.getByText("packed.drawio isn't a draw.io diagram."),
|
|
).toBeVisible()
|
|
await expect(page.getByText("Create a flowchart").first()).toBeVisible()
|
|
await expect(page.locator('[data-testid="version-card"]')).toHaveCount(
|
|
1,
|
|
)
|
|
})
|
|
|
|
test("a file that is not a diagram leaves the start screen", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
const [chooser] = await Promise.all([
|
|
page.waitForEvent("filechooser"),
|
|
page
|
|
.getByRole("button", { name: "Open .drawio file" })
|
|
.first()
|
|
.click(),
|
|
])
|
|
await chooser.setFiles({
|
|
name: "notes.xml",
|
|
mimeType: "application/xml",
|
|
buffer: Buffer.from("<notes><note>hi</note></notes>"),
|
|
})
|
|
await expect(
|
|
page.getByText("notes.xml isn't a draw.io diagram."),
|
|
).toBeVisible()
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible()
|
|
})
|
|
|
|
test("draw.io loads once when the saved language is another", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("next-ai-draw-io-locale", "ja")
|
|
})
|
|
// Requests for draw.io's page, also one dropped for a newer frame
|
|
let loads = 0
|
|
page.on("request", (request) => {
|
|
if (new URL(request.url()).pathname.endsWith("/drawio/index.html"))
|
|
loads++
|
|
})
|
|
await page.goto("/en", { waitUntil: "networkidle" })
|
|
await expect(page).toHaveURL(/\/ja/)
|
|
await expect(
|
|
page.locator('[data-testid="canvas-loading"]'),
|
|
).toHaveCount(0, { timeout: 30000 })
|
|
await page.waitForTimeout(1000)
|
|
expect(loads).toBe(1)
|
|
})
|
|
|
|
test("in a small window the chat panel leaves the canvas room and draw.io's toolbar fits", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("next-ai-draw-io-panel-width", "560")
|
|
})
|
|
await page.setViewportSize({ width: 800, height: 800 })
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
const toolbar = getIframeContent(page).locator(".geSimpleMainMenu")
|
|
await expect(toolbar).toBeVisible()
|
|
await page.waitForTimeout(600)
|
|
const panel = await boxOf(page.locator('[data-testid="chat-panel"]'))
|
|
const stage = await boxOf(page.locator('[data-testid="canvas-stage"]'))
|
|
expect(panel.width).toBeLessThanOrEqual(400)
|
|
expect(stage.width).toBeGreaterThanOrEqual(370)
|
|
// draw.io's toolbar fits the canvas, its diagram menu (the last
|
|
// button) included; the chat panel has the export button
|
|
const menu = await boxOf(toolbar.locator('a[title="Diagram"]'))
|
|
expect(menu.x).toBeGreaterThanOrEqual(stage.x)
|
|
expect(menu.x + menu.width).toBeLessThanOrEqual(stage.x + stage.width)
|
|
await expect(
|
|
page.locator('[data-testid="export-button"]'),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("the latest card keeps its undo when the AI writes default values", async ({
|
|
page,
|
|
}) => {
|
|
// x="0" and a 0,0 label offset: draw.io leaves these out when it
|
|
// saves the diagram again
|
|
const xml = `<mxCell id="a" value="Origin" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="120" height="60" as="geometry"/></mxCell><mxCell id="b" value="Next" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="200" y="0" width="120" height="60" as="geometry"/></mxCell><mxCell id="e" value="go" edge="1" parent="1" source="a" target="b"><mxGeometry relative="1" as="geometry"><mxPoint x="0" y="0" as="offset"/></mxGeometry></mxCell>`
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(xml, "Here it is."),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "Draw two boxes")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
await page.waitForTimeout(2000)
|
|
await expect(page.locator('[data-testid="version-undo"]')).toBeVisible()
|
|
})
|
|
|
|
test("the latest card keeps its undo on a page with its own paper size", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
// A3 paper; the AI's diagram names no paper size, so the page keeps it
|
|
await openDrawioFile(
|
|
page,
|
|
"a3.drawio",
|
|
`<mxfile><diagram name="P" id="p"><mxGraphModel pageWidth="1169" pageHeight="1654"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="Old" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
await expect(getIframeContent(page).getByText("Old")).toBeVisible()
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(2000)
|
|
await expect(page.locator('[data-testid="version-undo"]')).toHaveText(
|
|
"Undo change",
|
|
)
|
|
})
|
|
|
|
test("a hand edit after a restore takes the restore message's Undo away", 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()
|
|
const canvas = getIframeContent(page)
|
|
await canvas.getByText("Process", { exact: true }).click()
|
|
await page.keyboard.press("Delete")
|
|
await expect(canvas.getByText("Process", { exact: true })).toHaveCount(
|
|
0,
|
|
)
|
|
await expect(toast).toHaveCount(0)
|
|
})
|
|
|
|
test("changing the language saves the chat and keeps it open", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
// Right after the answer: its auto-save may not have run yet
|
|
await openSettingsTab(page, "appearance")
|
|
await page.locator("#language-select").click()
|
|
await page.getByRole("option", { name: "日本語", exact: true }).click()
|
|
await expect(page).toHaveURL(/\/ja\?session=/, { timeout: 15000 })
|
|
await expect(page.getByText("Create a flowchart").first()).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await expect(page.locator('[data-testid="version-card"]')).toHaveCount(
|
|
1,
|
|
)
|
|
})
|
|
|
|
test("the language can't change while an answer runs", async ({ page }) => {
|
|
await page.route("**/api/chat", async (route) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 5000))
|
|
await route
|
|
.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(FLOWCHART_XML, "Slow one."),
|
|
})
|
|
.catch(() => {})
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await openSettingsTab(page, "appearance")
|
|
await expect(page.locator("#language-select")).toBeDisabled()
|
|
})
|
|
|
|
test("opening a chat from the list does not save it again", async ({
|
|
page,
|
|
}) => {
|
|
let n = 0
|
|
await page.route("**/api/chat", (route) => {
|
|
n++
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(FLOWCHART_XML, `Answer ${n}.`),
|
|
})
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "First chat")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
await page.waitForTimeout(1500)
|
|
await page.locator('[data-testid="new-chat-button"]').click()
|
|
await sendMessage(page, "Second chat")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(2500)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page.getByRole("button", { name: /First chat/ }).click()
|
|
await expect(page.getByText("Answer 1.")).toBeVisible()
|
|
await page.waitForTimeout(500)
|
|
const opened = await sessionTimes(page)
|
|
await page.waitForTimeout(3000)
|
|
expect(await sessionTimes(page)).toEqual(opened)
|
|
})
|
|
|
|
test("retrying the first message does not flash the start screen", 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 new Promise((resolve) => setTimeout(resolve, 800))
|
|
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")
|
|
await expect(page.getByText("Model is overloaded").first()).toBeVisible(
|
|
{
|
|
timeout: 10000,
|
|
},
|
|
)
|
|
// Counts every moment the start screen is in the page
|
|
await page.evaluate(() => {
|
|
const w = window as any
|
|
w.__heroSeen = 0
|
|
new MutationObserver(() => {
|
|
if (document.querySelector('[data-testid="lobby-hero"]')) {
|
|
w.__heroSeen++
|
|
}
|
|
}).observe(document.body, { childList: true, subtree: true })
|
|
})
|
|
await page.locator('[data-testid="retry-button"]').click()
|
|
await waitForComplete(page)
|
|
expect(await page.evaluate(() => (window as any).__heroSeen)).toBe(0)
|
|
})
|
|
|
|
test("a retry stays with its chat when another one comes on screen during the export", async ({
|
|
page,
|
|
}) => {
|
|
// draw.io's export replies wait until released
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: MessageEvent[] = []
|
|
w.__releaseExports = () => {
|
|
w.__holdExports = false
|
|
for (const e of held.splice(0)) {
|
|
const copy = new MessageEvent("message", {
|
|
data: e.data,
|
|
origin: e.origin,
|
|
source: e.source,
|
|
})
|
|
;(copy as any).__released = true
|
|
window.dispatchEvent(copy)
|
|
}
|
|
}
|
|
window.addEventListener("message", (event) => {
|
|
if (
|
|
w.__holdExports &&
|
|
!(event as any).__released &&
|
|
typeof event.data === "string" &&
|
|
event.data.includes('"event":"export"')
|
|
) {
|
|
event.stopImmediatePropagation()
|
|
held.push(event)
|
|
}
|
|
})
|
|
})
|
|
const prompts: string[] = []
|
|
await page.route("**/api/chat", async (route) => {
|
|
const body = route.request().postDataJSON()
|
|
const last = body.messages.at(-1)
|
|
prompts.push(last.parts.find((p: any) => p.type === "text").text)
|
|
if (prompts.length === 2) {
|
|
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, "Answer for B."),
|
|
})
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "Chat B")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
const chatB = new URL(page.url()).searchParams.get("session")
|
|
await page.waitForTimeout(1200)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: "New diagram" })
|
|
.click()
|
|
await sendMessage(page, "Chat A")
|
|
await expect(page.locator('[data-testid="retry-button"]')).toBeVisible({
|
|
timeout: 10000,
|
|
})
|
|
await page.waitForTimeout(1200)
|
|
|
|
await page.evaluate(() => {
|
|
;(window as any).__holdExports = true
|
|
})
|
|
await page.locator('[data-testid="retry-button"]').click()
|
|
// Chat B comes on screen through the URL (as browser back would)
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
await expect(page.getByText("Answer for B.")).toBeVisible()
|
|
await page.evaluate(() => (window as any).__releaseExports())
|
|
await page.waitForTimeout(1500)
|
|
expect(prompts).toHaveLength(2)
|
|
await expect(page.getByText("Answer for B.")).toBeVisible()
|
|
await expect(page.getByText("Chat A", { exact: true })).toHaveCount(0)
|
|
})
|
|
|
|
test("a retry keeps what was drawn by hand after the error", async ({
|
|
page,
|
|
}) => {
|
|
const bodies: any[] = []
|
|
await page.route("**/api/chat", async (route) => {
|
|
bodies.push(route.request().postDataJSON())
|
|
if (bodies.length === 2) {
|
|
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:
|
|
bodies.length === 1
|
|
? createMockSSEResponse(FLOWCHART_XML, "Flowchart.")
|
|
: createEditResponse(),
|
|
})
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await sendMessage(page, "Add a review step")
|
|
await expect(page.locator('[data-testid="retry-button"]')).toBeVisible({
|
|
timeout: 10000,
|
|
})
|
|
// A copy of "Start", made by hand
|
|
const canvas = getIframeContent(page)
|
|
await canvas.getByText("Start", { exact: true }).click()
|
|
await page.keyboard.press("ControlOrMeta+d")
|
|
await expect(canvas.getByText("Start", { exact: true })).toHaveCount(2)
|
|
await page.waitForTimeout(1200)
|
|
|
|
await page.locator('[data-testid="retry-button"]').click()
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
await expect(canvas.getByText("Start", { exact: true })).toHaveCount(2)
|
|
// The model was sent the canvas with the copy
|
|
expect(bodies[2].xml.match(/value="Start"/g)).toHaveLength(2)
|
|
await expect(page.locator('[data-testid="version-undo"]')).toHaveText(
|
|
"Undo change",
|
|
)
|
|
})
|
|
|
|
test("page settings draw.io keeps in place follow the AI's diagram", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
// A page shadow, which replacing the page in place would keep
|
|
await openDrawioFile(
|
|
page,
|
|
"shadow.drawio",
|
|
`<mxfile><diagram name="P" id="p"><mxGraphModel shadow="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="Shady" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Shady")).toBeVisible()
|
|
const shadowed = () =>
|
|
canvas
|
|
.locator(".geDiagramContainer svg g")
|
|
.evaluateAll((els) =>
|
|
els.some((el) =>
|
|
(el as SVGElement).style.filter.includes("drop-shadow"),
|
|
),
|
|
)
|
|
expect(await shadowed()).toBe(true)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("Process", { exact: true })).toBeVisible()
|
|
await expect.poll(shadowed).toBe(false)
|
|
})
|
|
|
|
test("a restored version gets its page id back, for links to the page", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createEditResponse(),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"linked.drawio",
|
|
`<mxfile><diagram name="Main" id="orig"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject label="Home" link="data:page/id,orig" id="a"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></UserObject></root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Home")).toBeVisible()
|
|
await sendMessage(page, "Add a review step")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
|
|
// Another page takes the place of the first one
|
|
const tabs = drawioTabs(page)
|
|
const main = tabs.locator(".gePageTab", { hasText: "Main" })
|
|
await tabs.locator('[title="Insert Page"]').click()
|
|
await main.click()
|
|
// The menu button shows on the current page's tab
|
|
await main.locator(".geButton").click()
|
|
await canvas
|
|
.locator(".mxPopupMenu")
|
|
.last()
|
|
.getByText("Remove Page", { exact: true })
|
|
.click()
|
|
await expect(main).toHaveCount(0)
|
|
|
|
await page.locator('[data-testid="version-restore"]').click()
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
const savedPageId = () =>
|
|
page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const sessions: any[] = await new Promise((resolve) => {
|
|
const request = db
|
|
.transaction("sessions")
|
|
.objectStore("sessions")
|
|
.getAll()
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
db.close()
|
|
const xml = sessions[0]?.diagramXml ?? ""
|
|
return new DOMParser()
|
|
.parseFromString(xml, "text/xml")
|
|
.querySelector("diagram")
|
|
?.getAttribute("id")
|
|
})
|
|
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
|
})
|
|
|
|
test("Ctrl+Z after an AI drawing gives the page its id back", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"linked.drawio",
|
|
`<mxfile><diagram name="Main" id="orig"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject label="Home" link="data:page/id,orig" id="a"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></UserObject></root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Home")).toBeVisible()
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("Process", { exact: true })).toBeVisible()
|
|
const savedPageId = () =>
|
|
page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const sessions: any[] = await new Promise((resolve) => {
|
|
const request = db
|
|
.transaction("sessions")
|
|
.objectStore("sessions")
|
|
.getAll()
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
db.close()
|
|
return new DOMParser()
|
|
.parseFromString(sessions[0]?.diagramXml ?? "", "text/xml")
|
|
.querySelector("diagram")
|
|
?.getAttribute("id")
|
|
})
|
|
await expect.poll(savedPageId, { timeout: 10000 }).toBe("page-1")
|
|
await canvas
|
|
.locator(".geDiagramContainer")
|
|
.click({ position: { x: 10, y: 10 } })
|
|
await page.keyboard.press("ControlOrMeta+z")
|
|
await expect(canvas.getByText("Home")).toBeVisible()
|
|
await expect.poll(savedPageId, { timeout: 10000 }).toBe("orig")
|
|
await page.keyboard.press("ControlOrMeta+Shift+z")
|
|
await expect(canvas.getByText("Process", { exact: true })).toBeVisible()
|
|
await expect.poll(savedPageId, { timeout: 10000 }).toBe("page-1")
|
|
})
|
|
|
|
test("page settings the AI writes in single quotes are applied too", async ({
|
|
page,
|
|
}) => {
|
|
const shady = `<mxGraphModel shadow='1'><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="Shady" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
|
let count = 0
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body:
|
|
count++ === 0
|
|
? createMockSSEResponse(FLOWCHART_XML, "Flowchart.")
|
|
: createMockSSEResponse(shady, "With a shadow."),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await sendMessage(page, "Redraw it with a shadow")
|
|
await waitForCompleteCount(page, 2)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Shady")).toBeVisible()
|
|
await expect
|
|
.poll(() =>
|
|
canvas
|
|
.locator(".geDiagramContainer svg g")
|
|
.evaluateAll((els) =>
|
|
els.some((el) =>
|
|
(el as SVGElement).style.filter.includes(
|
|
"drop-shadow",
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toBe(true)
|
|
})
|
|
|
|
test("an opened blank file is shown also from a chat with messages", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await openDrawioFile(page, "blank.drawio", BLANK_SHEET)
|
|
await expect(drawioTabs(page)).toContainText("Sheet")
|
|
await page.waitForTimeout(800)
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toHaveCount(0)
|
|
await expect(
|
|
page.locator('[data-testid="session-title"]'),
|
|
).toContainText("blank")
|
|
})
|
|
|
|
test("an opened blank file is saved: a language switch and a reload bring it back", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await openDrawioFile(page, "blank.drawio", BLANK_SHEET)
|
|
const tabs = drawioTabs(page)
|
|
await expect(tabs).toContainText("Sheet")
|
|
// Right away, before the chat is saved on its own
|
|
await openSettingsTab(page, "appearance")
|
|
await page.locator("#language-select").click()
|
|
await page.getByRole("option", { name: "日本語", exact: true }).click()
|
|
await expect(page).toHaveURL(/\/ja\?session=/, { timeout: 15000 })
|
|
const background = () =>
|
|
getIframeContent(page)
|
|
.locator(".geBackgroundPage")
|
|
.evaluate((el) => getComputedStyle(el).backgroundColor)
|
|
for (const step of ["switch", "reload"]) {
|
|
if (step === "reload") {
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
}
|
|
await expect(tabs).toContainText("Sheet", { timeout: 30000 })
|
|
await expect.poll(background).toBe("rgb(255, 238, 204)")
|
|
await expect(
|
|
page.locator('[data-testid="lobby-hero"]'),
|
|
).toHaveCount(0)
|
|
await expect(
|
|
page.locator('[data-testid="session-title"]'),
|
|
).toContainText("blank")
|
|
}
|
|
})
|
|
|
|
test("a rename while the first save is being written is kept", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
// Hold the sessions store: the first save waits to be written
|
|
await page.evaluate(async () => {
|
|
const w = window as any
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const store = db
|
|
.transaction("sessions", "readwrite")
|
|
.objectStore("sessions")
|
|
const keepBusy = () => {
|
|
if (w.__release) return db.close()
|
|
store.get("none").onsuccess = keepBusy
|
|
}
|
|
keepBusy()
|
|
})
|
|
await page.waitForTimeout(1500)
|
|
await expect(page).not.toHaveURL(/session=/)
|
|
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 page.evaluate(() => {
|
|
;(window as any).__release = true
|
|
})
|
|
await expect(page).toHaveURL(/session=/, { timeout: 10000 })
|
|
await expect
|
|
.poll(() => sessionTimes(page).then((t) => Object.keys(t)))
|
|
.toEqual(["Order flow"])
|
|
await expect(
|
|
page.locator('[data-testid="session-title"]'),
|
|
).toContainText("Order flow")
|
|
})
|
|
|
|
test("choosing a chat deleted in another tab keeps this chat's session in the URL", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
await page.waitForTimeout(1200)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: "New diagram" })
|
|
.click()
|
|
await expect(getChatInput(page)).toBeFocused()
|
|
// Another tab deletes the flowchart chat
|
|
await page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
tx.objectStore("sessions").clear()
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
})
|
|
await openDrawioFile(page, "mine.drawio", TWO_PAGE_FILE)
|
|
await expect(getIframeContent(page).getByText("Old A")).toBeVisible()
|
|
// Before this chat saves on its own: choosing the gone chat saves it
|
|
await expect(page).not.toHaveURL(/session=/)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page.getByRole("button", { name: /Create a flowchart/ }).click()
|
|
await expect(page).toHaveURL(/session=/, { timeout: 10000 })
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await expect(
|
|
page.getByRole("button", { name: /Create a flowchart/ }),
|
|
).toHaveCount(0)
|
|
await page.keyboard.press("Escape")
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await expect(getIframeContent(page).getByText("Old A")).toBeVisible({
|
|
timeout: 30000,
|
|
})
|
|
})
|
|
|
|
test("a title typed for an empty chat stays with that chat", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
await page.waitForTimeout(1200)
|
|
// A new chat, named before anything is in it
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: "New diagram" })
|
|
.click()
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible()
|
|
// The new chat puts the focus in the composer
|
|
await expect(getChatInput(page)).toBeFocused()
|
|
const recent = page.getByRole("button", { name: "Recent diagrams" })
|
|
await recent.click()
|
|
await page.locator('[data-testid="rename-session"]').click()
|
|
const input = page.getByRole("textbox", { name: "Rename" })
|
|
await input.fill("Foo")
|
|
await input.press("Enter")
|
|
// Open the flowchart chat, then delete it
|
|
await page.getByRole("button", { name: /Create a flowchart/ }).click()
|
|
await expect(
|
|
getIframeContent(page).getByText("Process", { exact: true }),
|
|
).toBeVisible()
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("button", { name: /Create a flowchart/ })
|
|
.getByRole("button", { name: "Delete this diagram?" })
|
|
.click()
|
|
await page.getByRole("button", { name: "Delete", exact: true }).click()
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible()
|
|
// The empty chat on screen now has no title of its own; "Foo" was
|
|
// kept for the chat it was typed in
|
|
await recent.click()
|
|
await expect(page.locator('[data-testid="rename-session"]')).toHaveText(
|
|
"Untitled diagram",
|
|
)
|
|
await expect(page.getByRole("button", { name: /^Foo/ })).toBeVisible()
|
|
})
|
|
|
|
test("an opened file without shapes is shown, page settings included", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
const [chooser] = await Promise.all([
|
|
page.waitForEvent("filechooser"),
|
|
page
|
|
.getByRole("button", { name: "Open .drawio file" })
|
|
.first()
|
|
.click(),
|
|
])
|
|
await chooser.setFiles({
|
|
name: "blank.drawio",
|
|
mimeType: "application/xml",
|
|
buffer: Buffer.from(
|
|
'<mxfile><diagram name="Sheet" id="s"><mxGraphModel background="#ffeecc"><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
|
|
),
|
|
})
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toHaveCount(0)
|
|
await expect(drawioTabs(page)).toContainText("Sheet")
|
|
await expect
|
|
.poll(() =>
|
|
getIframeContent(page)
|
|
.locator(".geBackgroundPage")
|
|
.evaluate((el) => getComputedStyle(el).backgroundColor),
|
|
)
|
|
.toBe("rgb(255, 238, 204)")
|
|
})
|
|
|
|
test("the canvas edge follows a panel narrowed by the window at once", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem("next-ai-draw-io-panel-width", "560")
|
|
})
|
|
await page.setViewportSize({ width: 1280, height: 800 })
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
await page.waitForTimeout(800)
|
|
// 900 px leaves the panel 500 px: the canvas edge moves with it
|
|
await page.setViewportSize({ width: 900, height: 800 })
|
|
await page.waitForTimeout(50)
|
|
const panel = await boxOf(page.locator('[data-testid="chat-panel"]'))
|
|
const stage = await boxOf(page.locator('[data-testid="canvas-stage"]'))
|
|
expect(Math.abs(stage.x + stage.width - (panel.x - 12))).toBeLessThan(3)
|
|
})
|
|
|
|
test("closing the panel after dragging its edge keeps the view's middle", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
// Past the time a resize fits the new diagram again; a zoom by
|
|
// the user keeps the view from then on
|
|
await page.waitForTimeout(4500)
|
|
await zoomCanvas(page, "out")
|
|
await page.waitForTimeout(500)
|
|
const handle = await boxOf(page.getByRole("separator"))
|
|
await page.mouse.move(handle.x + handle.width / 2, handle.y + 100)
|
|
await page.mouse.down()
|
|
await page.mouse.move(handle.x - 150, handle.y + 100, { steps: 8 })
|
|
await page.mouse.up()
|
|
await page.waitForTimeout(600)
|
|
const offset = async () => {
|
|
const stage = await boxOf(
|
|
page.locator('[data-testid="canvas-stage"]'),
|
|
)
|
|
const shape = await boxOf(
|
|
getIframeContent(page).getByText("Process", { exact: true }),
|
|
)
|
|
return shape.x + shape.width / 2 - (stage.x + stage.width / 2)
|
|
}
|
|
const before = await offset()
|
|
await page.getByRole("button", { name: "Hide chat panel" }).click()
|
|
await page.waitForTimeout(900)
|
|
expect(Math.abs((await offset()) - before)).toBeLessThan(6)
|
|
})
|
|
|
|
test("compare explains a change in the order of shapes", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1500)
|
|
// Bring the first shape to the front: only the order changes
|
|
await getIframeContent(page).getByText("Start", { exact: true }).click()
|
|
await page.keyboard.press("ControlOrMeta+Shift+F")
|
|
await page.waitForTimeout(1000)
|
|
await page
|
|
.locator('[data-testid="version-card"]')
|
|
.getByRole("button", { name: "Compare" })
|
|
.click()
|
|
await expect(page.getByRole("dialog")).toContainText("their order")
|
|
await expect(
|
|
page.locator('[data-testid="compare-restore"]'),
|
|
).toBeEnabled()
|
|
})
|
|
|
|
test("a file name typed in the export dialog stays when the title comes", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", async (route) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 2500))
|
|
await route
|
|
.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(FLOWCHART_XML, "Here it is."),
|
|
})
|
|
.catch(() => {})
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await page.locator('[data-testid="export-button"]').click()
|
|
const name = page.getByRole("dialog").getByRole("textbox")
|
|
await name.fill("my-arch")
|
|
// The answer ends and the chat is saved: it gets its title now
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/, { timeout: 10000 })
|
|
await page.waitForTimeout(1000)
|
|
await expect(name).toHaveValue("my-arch")
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Edge cases found in review
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const sseOf = (events: object[], done = true) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
|
|
(done ? "data: [DONE]\n\n" : "")
|
|
const boxCell = (id: string, label: string, x: number, parent = "1") =>
|
|
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="${parent}"><mxGeometry x="${x}" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
|
|
|
/**
|
|
* Replies to /api/chat in order, each a list of chunks sent 800 ms apart.
|
|
* With holdLast, the last reply never ends.
|
|
*/
|
|
async function slowReplies(page: Page, replies: string[][], holdLast = false) {
|
|
await page.addInitScript(
|
|
({ replies, holdLast }) => {
|
|
const realFetch = window.fetch
|
|
let n = 0
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const index = n++
|
|
const chunks = replies[index] ?? []
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
for (const chunk of chunks) {
|
|
c.enqueue(new TextEncoder().encode(chunk))
|
|
await new Promise((r) => setTimeout(r, 800))
|
|
}
|
|
if (!(holdLast && index === replies.length - 1)) {
|
|
c.close()
|
|
}
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
},
|
|
{ replies, holdLast },
|
|
)
|
|
}
|
|
|
|
/** Puts a saved chat into the browser's storage */
|
|
async function seedSession(page: Page, session: object) {
|
|
await page.evaluate(async (session) => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
tx.objectStore("sessions").put(session)
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
}, session)
|
|
}
|
|
|
|
/** The diagram of a saved chat (the first one when no id is given) */
|
|
function savedDiagram(page: Page, id?: string) {
|
|
return page.evaluate(async (id) => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const sessions: any[] = await new Promise((resolve) => {
|
|
const request = db
|
|
.transaction("sessions")
|
|
.objectStore("sessions")
|
|
.getAll()
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
db.close()
|
|
const session = id ? sessions.find((s) => s.id === id) : sessions[0]
|
|
return (session?.diagramXml ?? "") as string
|
|
}, id)
|
|
}
|
|
|
|
/** Records the XML of draw.io's latest autosave */
|
|
async function watchAutosave(page: Page) {
|
|
await page.addInitScript(() => {
|
|
window.addEventListener("message", (e) => {
|
|
if (
|
|
typeof e.data === "string" &&
|
|
e.data.includes('"event":"autosave"')
|
|
) {
|
|
;(window as any).__lastAutosave = JSON.parse(e.data).xml
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/** The x of a shape in draw.io's latest autosave */
|
|
function savedX(page: Page, id: string) {
|
|
return page.evaluate((id) => {
|
|
const xml: string = (window as any).__lastAutosave ?? ""
|
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
|
const cell = Array.from(doc.getElementsByTagName("mxCell")).find(
|
|
(c) => c.getAttribute("id") === id,
|
|
)
|
|
return (
|
|
cell?.getElementsByTagName("mxGeometry")[0]?.getAttribute("x") ??
|
|
null
|
|
)
|
|
}, id)
|
|
}
|
|
|
|
/** Whether a label on the canvas lies inside the canvas frame */
|
|
async function inView(page: Page, label: string) {
|
|
const frame = await getIframe(page).boundingBox()
|
|
const box = await getIframeContent(page)
|
|
.getByText(label, { exact: true })
|
|
.boundingBox()
|
|
if (!frame || !box) return false
|
|
return (
|
|
box.x >= frame.x &&
|
|
box.y >= frame.y &&
|
|
box.x + box.width <= frame.x + frame.width &&
|
|
box.y + box.height <= frame.y + frame.height
|
|
)
|
|
}
|
|
|
|
const ONE_PIXEL_PNG = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
)
|
|
|
|
/** Records the text of each message sent to /api/chat */
|
|
async function recordPrompts(page: Page) {
|
|
const prompts: string[] = []
|
|
await page.route("**/api/chat", async (route) => {
|
|
const last = route.request().postDataJSON().messages.at(-1)
|
|
prompts.push(last.parts.find((p: any) => p.type === "text").text)
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(
|
|
FLOWCHART_XML,
|
|
`Answer ${prompts.length}.`,
|
|
),
|
|
})
|
|
})
|
|
return prompts
|
|
}
|
|
|
|
/** Chat B is saved, then chat A is on screen with one message */
|
|
async function twoChats(page: Page) {
|
|
await sendMessage(page, "Chat B")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
const chatB = new URL(page.url()).searchParams.get("session")
|
|
await page.waitForTimeout(1200)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: "New diagram" })
|
|
.click()
|
|
// Its address goes first: a URL change while that one is still on the
|
|
// way would replace it, and the page would never see a change
|
|
await expect(page).not.toHaveURL(/session=/)
|
|
await sendMessage(page, "Chat A one")
|
|
await waitForCompleteCount(page, 1)
|
|
await page.waitForTimeout(1200)
|
|
return chatB
|
|
}
|
|
|
|
/** Streamed display_diagram deltas for this input, the last one held back */
|
|
function streamedDisplay(id: string, input: object) {
|
|
const deltas = (JSON.stringify(input).match(/[\s\S]{1,30}/g) ?? []).map(
|
|
(d) => ({
|
|
type: "tool-input-delta",
|
|
toolCallId: id,
|
|
inputTextDelta: d,
|
|
}),
|
|
)
|
|
return {
|
|
head: sseOf(
|
|
[
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: id,
|
|
toolName: "display_diagram",
|
|
},
|
|
...deltas.slice(0, -1),
|
|
],
|
|
false,
|
|
),
|
|
last: deltas.at(-1) as object,
|
|
}
|
|
}
|
|
|
|
async function saveTemplate(page: Page) {
|
|
await getChatInput(page).fill("Template prompt")
|
|
await page.getByRole("button", { name: "Add", exact: true }).click()
|
|
await page.getByText("Save this prompt").click()
|
|
await page.getByRole("button", { name: "Save prompt" }).click()
|
|
await page.waitForTimeout(500)
|
|
await getChatInput(page).fill("")
|
|
}
|
|
|
|
/**
|
|
* Answers by prompt: "Chat A two" streams some text and starts a drawing,
|
|
* then pauses for a long time; every other prompt is answered at once
|
|
*/
|
|
async function answerWithPause(page: Page) {
|
|
await page.addInitScript(() => {
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const cell = (label: string) =>
|
|
`<mxCell id="a" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
|
const realFetch = window.fetch
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const body = JSON.parse(String(init?.body ?? "{}"))
|
|
const prompt =
|
|
body.messages
|
|
?.at(-1)
|
|
?.parts?.find((p: any) => p.type === "text")?.text ?? ""
|
|
const stream = new ReadableStream({
|
|
async start(c) {
|
|
const send = (events: object[]) =>
|
|
c.enqueue(new TextEncoder().encode(sse(events)))
|
|
if (prompt !== "Chat A two") {
|
|
send([
|
|
{ type: "start" },
|
|
{ type: "text-start", id: "t" },
|
|
{
|
|
type: "text-delta",
|
|
id: "t",
|
|
delta: `Answer to ${prompt}.`,
|
|
},
|
|
{ type: "text-end", id: "t" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: `c-${prompt}`,
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: `c-${prompt}`,
|
|
toolName: "display_diagram",
|
|
input: { xml: cell(prompt) },
|
|
},
|
|
{ type: "finish" },
|
|
])
|
|
c.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
|
|
c.close()
|
|
return
|
|
}
|
|
send([
|
|
{ type: "start" },
|
|
{ type: "text-start", id: "t2" },
|
|
{
|
|
type: "text-delta",
|
|
id: "t2",
|
|
delta: "A is answering.",
|
|
},
|
|
{ type: "text-end", id: "t2" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d2",
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-delta",
|
|
toolCallId: "d2",
|
|
inputTextDelta: '{"xml":"<mxCell',
|
|
},
|
|
])
|
|
await new Promise((r) => setTimeout(r, 60000))
|
|
c.close()
|
|
},
|
|
})
|
|
return new Response(stream, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
/** The text of every saved chat's messages */
|
|
function savedChats(page: Page) {
|
|
return page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const all: any[] = await new Promise((resolve) => {
|
|
const request = db
|
|
.transaction("sessions")
|
|
.objectStore("sessions")
|
|
.getAll()
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
db.close()
|
|
return all.map((s) => ({
|
|
id: s.id as string,
|
|
texts: s.messages.map((m: any) =>
|
|
m.parts
|
|
.filter((p: any) => p.type === "text")
|
|
.map((p: any) => p.text)
|
|
.join(" "),
|
|
) as string[],
|
|
}))
|
|
})
|
|
}
|
|
|
|
test.describe("Edge cases", () => {
|
|
test("committing a streamed drawing sends draw.io's autosave no older diagram", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
w.__autosaves = []
|
|
window.addEventListener("message", (e) => {
|
|
if (
|
|
typeof e.data === "string" &&
|
|
e.data.includes('"event":"autosave"')
|
|
) {
|
|
const xml = JSON.parse(e.data).xml as string
|
|
w.__autosaves.push(xml.includes("NewOne") ? "new" : "old")
|
|
}
|
|
})
|
|
})
|
|
const input = {
|
|
xml: boxCell("a", "OldOne", 40) + boxCell("b", "NewOne", 240),
|
|
}
|
|
const stream = streamedDisplay("d2", input)
|
|
await slowReplies(page, [
|
|
[createMockSSEResponse(boxCell("a", "OldOne", 40), "One.")],
|
|
[
|
|
stream.head,
|
|
sseOf([
|
|
stream.last,
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: "d2",
|
|
toolName: "display_diagram",
|
|
input,
|
|
},
|
|
{ type: "finish" },
|
|
]),
|
|
],
|
|
])
|
|
await openApp(page)
|
|
await sendMessage(page, "One")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1500)
|
|
await page.evaluate(() => {
|
|
;(window as any).__autosaves = []
|
|
})
|
|
await sendMessage(page, "Two")
|
|
await expect(getIframeContent(page).getByText("NewOne")).toBeVisible()
|
|
await page.waitForTimeout(3000)
|
|
const seen: string[] = await page.evaluate(
|
|
() => (window as any).__autosaves,
|
|
)
|
|
expect(seen).toContain("new")
|
|
// Once the new diagram is on the canvas, no older one comes back
|
|
expect(seen.slice(seen.indexOf("new"))).not.toContain("old")
|
|
})
|
|
|
|
test("a reply that just ends in the middle of a drawing undoes its preview", async ({
|
|
page,
|
|
}) => {
|
|
const input = {
|
|
xml: boxCell("b", "Beta", 240) + boxCell("c", "Gamma", 440),
|
|
}
|
|
const stream = streamedDisplay("d2", input)
|
|
await slowReplies(page, [
|
|
[createMockSSEResponse(boxCell("a", "Alpha", 40), "One.")],
|
|
[stream.head],
|
|
])
|
|
await openApp(page)
|
|
await sendMessage(page, "One")
|
|
await waitForComplete(page)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
|
await sendMessage(page, "Two")
|
|
await expect(
|
|
page.getByText("This attempt failed", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
|
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
|
})
|
|
|
|
test("an AI edit of a document with adaptive colors keeps the undo history", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createEditResponse(),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"adaptive.drawio",
|
|
`<mxfile><diagram name="P" id="p"><mxGraphModel adaptiveColors="auto"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "Alpha", 40)}</root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Alpha")).toBeVisible()
|
|
await sendMessage(page, "Add a review step")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
await expect(drawioUndo(page)).not.toHaveAttribute("disabled")
|
|
})
|
|
|
|
test("attachments in a short window keep the send button on screen", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await page.setViewportSize({ width: 900, height: 400 })
|
|
await openApp(page)
|
|
// The chat panel's composer, not the start screen's
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
const png = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
)
|
|
await getAttachmentInput(page).setInputFiles(
|
|
Array.from({ length: 5 }, (_, i) => ({
|
|
name: `img${i}.png`,
|
|
mimeType: "image/png",
|
|
buffer: png,
|
|
})),
|
|
)
|
|
await getChatInput(page).fill(
|
|
Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n"),
|
|
)
|
|
await expect(
|
|
page.locator('[data-testid="chat-panel"] img[alt="img4.png"]'),
|
|
).toBeAttached()
|
|
const send = await boxOf(getSendButton(page))
|
|
expect(send.y + send.height).toBeLessThanOrEqual(400)
|
|
})
|
|
|
|
test("a renamed empty chat still opens on the start screen", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1200)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: "New diagram" })
|
|
.click()
|
|
await expect(getChatInput(page)).toBeFocused()
|
|
await page.getByRole("button", { name: "Recent diagrams" }).click()
|
|
await page.locator('[data-testid="rename-session"]').click()
|
|
const input = page.getByRole("textbox", { name: "Rename" })
|
|
await input.fill("Foo")
|
|
await input.press("Enter")
|
|
await page.keyboard.press("Escape")
|
|
await expect(page).toHaveURL(/session=/, { timeout: 10000 })
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible({
|
|
timeout: 30000,
|
|
})
|
|
})
|
|
|
|
test("restoring a version keeps the zoom when the first layer is empty", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: sseOf([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: `e${Date.now()}`,
|
|
toolName: "edit_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: `e${Date.now()}`,
|
|
toolName: "edit_diagram",
|
|
input: {
|
|
operations: [
|
|
{
|
|
operation: "add",
|
|
cell_id: `n${Date.now()}`,
|
|
new_xml: boxCell(
|
|
`n${Date.now()}`,
|
|
"Added",
|
|
400,
|
|
"L2",
|
|
),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{ type: "finish" },
|
|
]),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"layers.drawio",
|
|
`<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="L2" value="Top" parent="0"/>${boxCell("a", "OnTop", 40, "L2")}</root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("OnTop")).toBeVisible()
|
|
await sendMessage(page, "Add one")
|
|
await waitForComplete(page)
|
|
await sendMessage(page, "Add another")
|
|
await waitForCompleteCount(page, 2)
|
|
// Above 100%, where fitting the view would bring it back
|
|
const initial = await paperWidth(page)
|
|
await zoomCanvas(page, "in")
|
|
await expect.poll(() => paperWidth(page)).toBeGreaterThan(initial)
|
|
const zoomed = await paperWidth(page)
|
|
await page.locator('[data-testid="version-restore"]').first().click()
|
|
await page.waitForTimeout(1000)
|
|
expect(Math.abs((await paperWidth(page)) - zoomed)).toBeLessThan(3)
|
|
})
|
|
|
|
test("an editable SVG with a prefixed root opens", async ({ page }) => {
|
|
await openApp(page)
|
|
const file = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "Prefixed", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
const escaped = file
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
await page
|
|
.locator('input[type="file"][accept^=".drawio"]')
|
|
.first()
|
|
.setInputFiles({
|
|
name: "prefixed.drawio.svg",
|
|
mimeType: "image/svg+xml",
|
|
buffer: Buffer.from(
|
|
`<svg:svg xmlns:svg="http://www.w3.org/2000/svg" content="${escaped}"></svg:svg>`,
|
|
),
|
|
})
|
|
await expect(getIframeContent(page).getByText("Prefixed")).toBeVisible()
|
|
})
|
|
|
|
test("an editable SVG whose DOCTYPE defines an entity opens", 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="&team;" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
|
// Escaped for the attribute, the entity reference left as it is
|
|
const escaped = file
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
await page
|
|
.locator('input[type="file"][accept^=".drawio"]')
|
|
.first()
|
|
.setInputFiles({
|
|
name: "entity.drawio.svg",
|
|
mimeType: "image/svg+xml",
|
|
buffer: Buffer.from(
|
|
`<?xml version="1.0"?>\n<!DOCTYPE svg [<!ENTITY team "Alpha">]>\n<svg xmlns="http://www.w3.org/2000/svg" content="${escaped}"></svg>`,
|
|
),
|
|
})
|
|
await expect(getIframeContent(page).getByText("Alpha")).toBeVisible()
|
|
})
|
|
|
|
test("the same file variables in another order keep the undo history", async ({
|
|
page,
|
|
}) => {
|
|
const file = (vars: string, label: string) =>
|
|
`<mxfile vars="${vars}"><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", label, 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(
|
|
file(
|
|
"{"a,b":"c","a":"b,c"}",
|
|
"After",
|
|
),
|
|
"Done.",
|
|
),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"vars.drawio",
|
|
file(
|
|
"{"a":"b,c","a,b":"c"}",
|
|
"Before",
|
|
),
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Before")).toBeVisible()
|
|
await sendMessage(page, "Rename it")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("After")).toBeVisible()
|
|
await expect(drawioUndo(page)).not.toHaveAttribute("disabled")
|
|
})
|
|
|
|
test("a blank file with its own paper size reopens on its canvas", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"a3.drawio",
|
|
'<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel pageWidth="1169" pageHeight="1654"><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
|
|
)
|
|
await expect(
|
|
page.locator('[data-testid="session-title"]'),
|
|
).toContainText("a3")
|
|
await expect(page).toHaveURL(/session=/, { timeout: 10000 })
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
|
await page.waitForTimeout(1500)
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toHaveCount(0)
|
|
})
|
|
|
|
test("a file whose comment mentions <svg> still opens", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"note.drawio",
|
|
`<!-- exported from <svg> -->\n<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "Noted", 40)}</root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
await expect(getIframeContent(page).getByText("Noted")).toBeVisible()
|
|
})
|
|
|
|
test("a shape library call saved unfinished does not keep loading", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
// Saved while the call ran (the tab was hidden mid-answer)
|
|
await page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
tx.objectStore("sessions").put({
|
|
id: "lib",
|
|
title: "AWS please",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "u1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "AWS please" }],
|
|
},
|
|
{
|
|
id: "a1",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "tool-get_shape_library",
|
|
toolCallId: "lib1",
|
|
state: "input-available",
|
|
input: { library: "aws4" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: "",
|
|
versions: [],
|
|
})
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
})
|
|
await page.goto("/?session=lib", { waitUntil: "networkidle" })
|
|
await expect(page.getByText("AWS please").first()).toBeVisible({
|
|
timeout: 30000,
|
|
})
|
|
await expect(page.getByText("Stopped", { exact: true })).toBeVisible()
|
|
await expect(page.getByText("Loading a shape library…")).toHaveCount(0)
|
|
})
|
|
|
|
test("a document whose page name has an ampersand can be sent", async ({
|
|
page,
|
|
}) => {
|
|
const bodies: any[] = []
|
|
await page.route("**/api/chat", (route) => {
|
|
bodies.push(route.request().postDataJSON())
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(FLOWCHART_XML, "Done."),
|
|
})
|
|
})
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"amp.drawio",
|
|
`<mxfile><diagram name="R & D" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "Lab", 40)}</root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
await expect(
|
|
getIframeContent(page).getByText("Lab", { exact: true }),
|
|
).toBeVisible()
|
|
await sendMessage(page, "Tidy it up")
|
|
await expect.poll(() => bodies.length, { timeout: 15000 }).toBe(1)
|
|
expect(bodies[0].xml).toContain('value="Lab"')
|
|
})
|
|
|
|
test("variables of the AI's file are applied", async ({ page }) => {
|
|
const file = (team: string) =>
|
|
`<mxfile vars="{"team":"${team}"}"><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject label="Team %team%" placeholders="1" id="v"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="160" height="60" as="geometry"/></mxCell></UserObject></root></mxGraphModel></diagram></mxfile>`
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(file("New"), "Renamed."),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await openDrawioFile(page, "vars.drawio", file("Old"))
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Team Old")).toBeVisible()
|
|
await sendMessage(page, "Rename the team")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("Team New")).toBeVisible()
|
|
})
|
|
|
|
test("without browser storage, another language starts with no old versions", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(window, "indexedDB", {
|
|
value: null,
|
|
configurable: true,
|
|
})
|
|
})
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await openSettingsTab(page, "appearance")
|
|
await page.locator("#language-select").click()
|
|
await page.getByRole("option", { name: "日本語", exact: true }).click()
|
|
await expect(page).toHaveURL(/\/ja/, { timeout: 15000 })
|
|
await page.keyboard.press("Escape")
|
|
await page.getByRole("button", { name: /白紙のキャンバス/ }).click()
|
|
await page.waitForTimeout(800)
|
|
await expect(page.locator('[data-testid="version-thumb"]')).toHaveCount(
|
|
0,
|
|
)
|
|
})
|
|
|
|
test("an edit sent while another message is prepared is not lost", async ({
|
|
page,
|
|
}) => {
|
|
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", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await page.evaluate(() => {
|
|
;(window as any).__dropExports = true
|
|
})
|
|
// The next message waits for its diagram export
|
|
await sendMessage(page, "Second")
|
|
await page.getByText("First", { exact: true }).hover()
|
|
await page.getByRole("button", { name: "Edit message" }).click()
|
|
const editor = page.locator("textarea").filter({ hasText: "First" })
|
|
await editor.fill("First, edited")
|
|
await editor.press("ControlOrMeta+Enter")
|
|
// Not sent now, so the edit stays open with its text
|
|
await expect(
|
|
page.locator("textarea").filter({ hasText: "First, edited" }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("a message stays with its chat when another comes on screen during its export", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: MessageEvent[] = []
|
|
w.__releaseExports = () => {
|
|
w.__holdExports = false
|
|
for (const e of held.splice(0)) {
|
|
const copy = new MessageEvent("message", {
|
|
data: e.data,
|
|
origin: e.origin,
|
|
source: e.source,
|
|
})
|
|
;(copy as any).__released = true
|
|
window.dispatchEvent(copy)
|
|
}
|
|
}
|
|
window.addEventListener("message", (event) => {
|
|
if (
|
|
w.__holdExports &&
|
|
!(event as any).__released &&
|
|
typeof event.data === "string" &&
|
|
event.data.includes('"event":"export"')
|
|
) {
|
|
event.stopImmediatePropagation()
|
|
held.push(event)
|
|
}
|
|
})
|
|
})
|
|
const prompts: string[] = []
|
|
await page.route("**/api/chat", async (route) => {
|
|
const last = route.request().postDataJSON().messages.at(-1)
|
|
prompts.push(last.parts.find((p: any) => p.type === "text").text)
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(
|
|
FLOWCHART_XML,
|
|
`Answer ${prompts.length}.`,
|
|
),
|
|
})
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "Chat B")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
const chatB = new URL(page.url()).searchParams.get("session")
|
|
await page.waitForTimeout(1200)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: "New diagram" })
|
|
.click()
|
|
await sendMessage(page, "Chat A one")
|
|
await waitForCompleteCount(page, 1)
|
|
await page.waitForTimeout(1200)
|
|
await page.evaluate(() => {
|
|
;(window as any).__holdExports = true
|
|
})
|
|
await sendMessage(page, "Chat A two")
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
await expect(page.getByText("Answer 1.")).toBeVisible()
|
|
// Chat B gets a link attached meanwhile
|
|
await page.route("**/api/parse-url", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
json: { title: "Spec", content: "text", charCount: 4 },
|
|
}),
|
|
)
|
|
await page.getByRole("button", { name: "Add", exact: true }).click()
|
|
await page.getByText("Extract from URL").click()
|
|
await page
|
|
.getByPlaceholder("https://example.com/article")
|
|
.fill("https://example.com/spec")
|
|
await page.getByRole("button", { name: "Extract", exact: true }).click()
|
|
const link = page
|
|
.locator('[data-testid="chat-panel"]')
|
|
.getByText("Spec")
|
|
await expect(link.first()).toBeVisible()
|
|
await page.evaluate(() => (window as any).__releaseExports())
|
|
await page.waitForTimeout(1500)
|
|
expect(prompts).toEqual(["Chat B", "Chat A one"])
|
|
// Not sent; chat B's link stays as it was
|
|
await expect(link.first()).toBeVisible()
|
|
})
|
|
|
|
test("a link to a chat that can't be read for a moment keeps its URL", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
const id = new URL(page.url()).searchParams.get("session")
|
|
await page.waitForTimeout(1200)
|
|
// Reading this session fails while this page is open (storage busy)
|
|
await page.addInitScript((id) => {
|
|
const get = IDBObjectStore.prototype.get
|
|
IDBObjectStore.prototype.get = function (...args: any[]) {
|
|
if (args[0] === id) {
|
|
throw new DOMException("busy", "InvalidStateError")
|
|
}
|
|
return get.apply(this, args as any)
|
|
}
|
|
}, id)
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
|
await page.waitForTimeout(1500)
|
|
expect(new URL(page.url()).searchParams.get("session")).toBe(id)
|
|
})
|
|
|
|
test("a rename after another tab deleted the chat is kept", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
await page.waitForTimeout(1200)
|
|
// Another tab deletes it
|
|
await page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
tx.objectStore("sessions").clear()
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
})
|
|
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 sendMessage(page, "Add a review step")
|
|
await waitForCompleteCount(page, 2)
|
|
await expect
|
|
.poll(() => sessionTimes(page).then((t) => Object.keys(t)), {
|
|
timeout: 10000,
|
|
})
|
|
.toEqual(["Order flow"])
|
|
})
|
|
|
|
test("the chat list finds untitled chats by the name it shows", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1200)
|
|
// Saved with the default title, which the list shows translated
|
|
await page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const sessions: any[] = await new Promise((resolve) => {
|
|
const request = db
|
|
.transaction("sessions")
|
|
.objectStore("sessions")
|
|
.getAll()
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
tx.objectStore("sessions").put({
|
|
...sessions[0],
|
|
id: "untitled",
|
|
title: "New Chat",
|
|
})
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
})
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page.getByPlaceholder("Search diagrams…").fill("Untitled")
|
|
await expect(
|
|
page.getByRole("button", { name: /Untitled diagram/ }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("compare closes when another chat with the same old version ids comes on screen", async ({
|
|
page,
|
|
}) => {
|
|
const legacy = (id: string, label: string) => ({
|
|
id,
|
|
title: `Chat ${id}`,
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: `u-${id}`,
|
|
role: "user",
|
|
parts: [{ type: "text", text: `Draw ${label}` }],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", label, 40)}</root></mxGraphModel></diagram></mxfile>`,
|
|
diagramHistory: [
|
|
{ svg: "", xml: `<mxGraphModel><root/></mxGraphModel>` },
|
|
{ svg: "", xml: `<mxGraphModel><root/></mxGraphModel>` },
|
|
],
|
|
})
|
|
await openApp(page)
|
|
await page.evaluate(
|
|
async (sessions) => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
for (const s of sessions) tx.objectStore("sessions").put(s)
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
},
|
|
[legacy("A", "AlphaA"), legacy("B", "BetaB")],
|
|
)
|
|
await page.goto("/?session=A", { waitUntil: "networkidle" })
|
|
await expect(getIframeContent(page).getByText("AlphaA")).toBeVisible({
|
|
timeout: 30000,
|
|
})
|
|
await page.locator('[data-testid="version-thumb"]').first().click()
|
|
await expect(page.getByRole("dialog")).toBeVisible()
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=B")
|
|
})
|
|
await expect(getIframeContent(page).getByText("BetaB")).toBeVisible()
|
|
await expect(page.getByRole("dialog")).toHaveCount(0)
|
|
})
|
|
|
|
test("the text of an automatic retry shows", async ({ page }) => {
|
|
// A shape with the root cell's id fails, so the app asks again
|
|
const bad = boxCell("1", "Bad", 40, "0")
|
|
const call = (id: string, xml: string) => [
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: id,
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: id,
|
|
toolName: "display_diagram",
|
|
input: { xml },
|
|
},
|
|
]
|
|
const text = (id: string, delta: string) => [
|
|
{ type: "text-start", id },
|
|
{ type: "text-delta", id, delta },
|
|
{ type: "text-end", id },
|
|
]
|
|
const replies = [
|
|
sseOf([
|
|
{ type: "start", messageId: "m1" },
|
|
{ type: "start-step" },
|
|
...call("d1", bad),
|
|
...text("t1", "First words."),
|
|
{ type: "finish-step" },
|
|
{ type: "finish" },
|
|
]),
|
|
sseOf([
|
|
{ type: "start", messageId: "m1" },
|
|
{ type: "start-step" },
|
|
...text("t2", "Second words."),
|
|
...call("d2", boxCell("a", "Alpha", 40)),
|
|
{ type: "finish-step" },
|
|
{ type: "finish" },
|
|
]),
|
|
]
|
|
let n = 0
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body:
|
|
replies[n++] ??
|
|
sseOf([{ type: "start" }, { type: "finish" }]),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "Draw")
|
|
await expect(getIframeContent(page).getByText("Alpha")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await expect(page.getByText("First words.")).toBeVisible()
|
|
await expect(page.getByText("Second words.")).toBeVisible()
|
|
})
|
|
|
|
test("a message stays with its chat when another comes on screen while its image is read", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: (() => void)[] = []
|
|
const real = FileReader.prototype.readAsDataURL
|
|
FileReader.prototype.readAsDataURL = function (blob: Blob) {
|
|
if (!w.__holdReads) return real.call(this, blob)
|
|
held.push(() => real.call(this, blob))
|
|
}
|
|
w.__releaseReads = () => {
|
|
w.__holdReads = false
|
|
for (const go of held.splice(0)) go()
|
|
}
|
|
})
|
|
const prompts = await recordPrompts(page)
|
|
await openApp(page)
|
|
const chatB = await twoChats(page)
|
|
await getAttachmentInput(page).setInputFiles({
|
|
name: "img.png",
|
|
mimeType: "image/png",
|
|
buffer: ONE_PIXEL_PNG,
|
|
})
|
|
await page.waitForTimeout(500)
|
|
await page.evaluate(() => {
|
|
;(window as any).__holdReads = true
|
|
})
|
|
await sendMessage(page, "Chat A two")
|
|
await page.waitForTimeout(300)
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
await expect(page.getByText("Answer 1.")).toBeVisible()
|
|
await page.waitForTimeout(800)
|
|
await page.evaluate(() => (window as any).__releaseReads())
|
|
await page.waitForTimeout(2500)
|
|
expect(prompts).toEqual(["Chat B", "Chat A one"])
|
|
})
|
|
|
|
test("a send that failed to export keeps the selected shapes for the next try", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
window.addEventListener("message", (event) => {
|
|
if (
|
|
(window as any).__dropExports &&
|
|
typeof event.data === "string" &&
|
|
event.data.includes('"event":"export"')
|
|
) {
|
|
event.stopImmediatePropagation()
|
|
}
|
|
})
|
|
})
|
|
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()
|
|
const chip = page.locator('[data-testid="selection-chip"]')
|
|
await expect(chip).toContainText("Process")
|
|
await page.evaluate(() => {
|
|
;(window as any).__dropExports = true
|
|
})
|
|
await sendMessage(page, "Make this red")
|
|
// The export times out and the message comes back, its shape too
|
|
await expect(getChatInput(page)).toHaveValue("Make this red", {
|
|
timeout: 20000,
|
|
})
|
|
await expect(chip).toContainText("Process")
|
|
await page.evaluate(() => {
|
|
;(window as any).__dropExports = false
|
|
})
|
|
await sendMessage(page, "Make this red")
|
|
await expect.poll(() => bodies.length, { timeout: 15000 }).toBe(2)
|
|
expect(bodies[1].selectedCells).toEqual([
|
|
{ id: "process", label: "Process" },
|
|
])
|
|
})
|
|
|
|
test("a file whose pages share an id opens with both pages", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
const pageXml = (attrs: string, label: string) =>
|
|
`<diagram ${attrs}><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("x", label, 40)}</root></mxGraphModel></diagram>`
|
|
// The same id twice; and a page without one, which draw.io numbers
|
|
// by its place ("1") like the page before it
|
|
await openDrawioFile(
|
|
page,
|
|
"dup.drawio",
|
|
`<mxfile>${pageXml('id="dup" name="A"', "PageA")}${pageXml('id="dup" name="B"', "PageB")}</mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("PageA", { exact: true })).toBeVisible()
|
|
await openDrawioFile(
|
|
page,
|
|
"noid.drawio",
|
|
`<mxfile>${pageXml('id="1" name="C"', "PageC")}${pageXml('name="D"', "PageD")}</mxfile>`,
|
|
)
|
|
await expect(canvas.getByText("PageC", { exact: true })).toBeVisible()
|
|
const tabs = drawioTabs(page)
|
|
await expect(tabs.getByText("C", { exact: true })).toBeVisible()
|
|
await expect(tabs.getByText("D", { exact: true })).toBeVisible()
|
|
})
|
|
|
|
test("an image that can't be read stays in the composer and sending goes on", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const real = FileReader.prototype.readAsDataURL
|
|
FileReader.prototype.readAsDataURL = function (blob: Blob) {
|
|
if (!w.__failReads) return real.call(this, blob)
|
|
setTimeout(() => {
|
|
Object.defineProperty(this, "error", {
|
|
value: new DOMException("gone", "NotReadableError"),
|
|
})
|
|
this.onerror?.(new ProgressEvent("error") as any)
|
|
}, 50)
|
|
}
|
|
})
|
|
const prompts = await recordPrompts(page)
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await getAttachmentInput(page).setInputFiles({
|
|
name: "img.png",
|
|
mimeType: "image/png",
|
|
buffer: ONE_PIXEL_PNG,
|
|
})
|
|
await page.waitForTimeout(500)
|
|
await page.evaluate(() => {
|
|
;(window as any).__failReads = true
|
|
})
|
|
await sendMessage(page, "Second")
|
|
await expect(
|
|
page
|
|
.getByRole("region", { name: /Notifications/ })
|
|
.getByText("Failed to read file: img.png"),
|
|
).toBeVisible()
|
|
await expect(getChatInput(page)).toHaveValue("Second")
|
|
await page.evaluate(() => {
|
|
;(window as any).__failReads = false
|
|
})
|
|
// The file can be read again: the message goes out
|
|
await sendMessage(page, "Second")
|
|
await expect.poll(() => prompts.length, { timeout: 15000 }).toBe(2)
|
|
expect(prompts[1]).toBe("Second")
|
|
})
|
|
|
|
test("undoing an AI change that added file variables removes them", async ({
|
|
page,
|
|
}) => {
|
|
const file = (vars: string) =>
|
|
`<mxfile${vars}><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject label="Team %team%" placeholders="1" id="v"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="160" height="60" as="geometry"/></mxCell></UserObject></root></mxGraphModel></diagram></mxfile>`
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(
|
|
file(' vars="{"team":"New"}"'),
|
|
"Named.",
|
|
),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await openDrawioFile(page, "vars.drawio", file(""))
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Team %team%")).toBeVisible()
|
|
await sendMessage(page, "Name the team")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("Team New")).toBeVisible()
|
|
await page.waitForTimeout(1500)
|
|
await page.locator('[data-testid="version-undo"]').click()
|
|
await expect(page.getByText("Change undone")).toBeVisible()
|
|
await expect(canvas.getByText("Team %team%")).toBeVisible()
|
|
await expect(canvas.getByText("Team New")).toHaveCount(0)
|
|
})
|
|
|
|
test("an AI edit of a file with variables keeps the undo history", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createEditResponse(),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"vars.drawio",
|
|
`<mxfile vars="{"team":"Blue"}"><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject label="Team %team%" placeholders="1" id="v"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="160" height="60" as="geometry"/></mxCell></UserObject></root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Team Blue")).toBeVisible()
|
|
await page.waitForTimeout(1000)
|
|
await sendMessage(page, "Add a review step")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
await expect(canvas.getByText("Team Blue")).toBeVisible()
|
|
await expect(drawioUndo(page)).not.toHaveAttribute("disabled")
|
|
})
|
|
|
|
test("the latest card keeps its undo when the AI writes 10.0 as grid size", async ({
|
|
page,
|
|
}) => {
|
|
const xml = `<mxGraphModel gridSize="10.0" pageScale="1.0"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "Alpha", 40)}</root></mxGraphModel>`
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(xml, "Here it is."),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "Draw")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(2500)
|
|
await expect(page.locator('[data-testid="version-undo"]')).toHaveText(
|
|
"Undo change",
|
|
)
|
|
})
|
|
|
|
test("a first message from the start screen shows its answer, also after Ctrl+B there", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(
|
|
boxCell("a", "Alpha", 40),
|
|
"Here it is.",
|
|
),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await getChatInput(page).click()
|
|
await page.keyboard.press("ControlOrMeta+b")
|
|
await sendMessage(page, "Draw")
|
|
await expect(getIframeContent(page).getByText("Alpha")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await expect(page.getByText("Here it is.")).toBeVisible()
|
|
})
|
|
|
|
test("Enter on an attached link's toggle in a sent message opens it", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await page.route("**/api/parse-url", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
json: {
|
|
title: "Spec",
|
|
content: "The spec body text",
|
|
charCount: 18,
|
|
},
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await page.getByRole("button", { name: "Add", exact: true }).click()
|
|
await page.getByText("Extract from URL").click()
|
|
await page
|
|
.getByPlaceholder("https://example.com/article")
|
|
.fill("https://example.com/spec")
|
|
await page.getByRole("button", { name: "Extract", exact: true }).click()
|
|
await page.waitForTimeout(800)
|
|
await sendMessage(page, "Second")
|
|
await waitForCompleteCount(page, 2)
|
|
const toggle = page
|
|
.locator('[data-testid="chat-panel"]')
|
|
.getByRole("button", { name: /example\.com|Spec/ })
|
|
.last()
|
|
await toggle.focus()
|
|
await page.keyboard.press("Enter")
|
|
await expect(page.getByText("The spec body text")).toBeVisible()
|
|
await expect(
|
|
page.locator("textarea").filter({ hasText: "Second" }),
|
|
).toHaveCount(0)
|
|
})
|
|
|
|
test("selecting text or following a link in a sent message leaves it closed", async ({
|
|
page,
|
|
}) => {
|
|
// The link is followed in the browser; here it stays on the page
|
|
await page.addInitScript(() => {
|
|
document.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
if ((e.target as Element).closest?.("a")) e.preventDefault()
|
|
},
|
|
true,
|
|
)
|
|
})
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(
|
|
page,
|
|
"Follow [the spec](https://example.com/spec) closely",
|
|
)
|
|
await waitForComplete(page)
|
|
await page.getByRole("link", { name: "the spec" }).click()
|
|
await page.waitForTimeout(300)
|
|
const editor = page.locator("textarea").filter({ hasText: "the spec" })
|
|
await expect(editor).toHaveCount(0)
|
|
// Text selected with the mouse, to copy it
|
|
const words = page.getByText("closely", { exact: false }).first()
|
|
const box = await boxOf(words)
|
|
await page.mouse.move(box.x + 2, box.y + box.height / 2)
|
|
await page.mouse.down()
|
|
await page.mouse.move(box.x + box.width - 2, box.y + box.height / 2, {
|
|
steps: 8,
|
|
})
|
|
await page.mouse.up()
|
|
await page.waitForTimeout(300)
|
|
await expect(editor).toHaveCount(0)
|
|
})
|
|
|
|
test("a call saved before versions existed shows and copies its XML", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await seedSession(page, {
|
|
id: "old",
|
|
title: "Old chat",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "u1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Old chat" }],
|
|
},
|
|
{
|
|
id: "a1",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "tool-display_diagram",
|
|
toolCallId: "old1",
|
|
state: "output-available",
|
|
input: { xml: boxCell("a", "Alpha", 40) },
|
|
output: "Successfully displayed the diagram.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: "",
|
|
versions: [],
|
|
})
|
|
await page.goto("/?session=old", { waitUntil: "networkidle" })
|
|
const row = page.locator('[data-testid="tool-row"]')
|
|
await expect(row).toBeVisible({ timeout: 30000 })
|
|
await row.getByRole("button").first().click()
|
|
await expect(row).toContainText('value="Alpha"')
|
|
await expect(
|
|
row.getByRole("button", { name: "Copy response" }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("a drawing on an empty canvas with an extra layer is fitted when done", async ({
|
|
page,
|
|
}) => {
|
|
const near = boxCell("a", "NearOne", 40)
|
|
const far = `<mxCell id="b" value="FarOne" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="2600" y="1600" width="120" height="60" as="geometry"/></mxCell>`
|
|
const json = JSON.stringify({ xml: near + far })
|
|
const cut = json.indexOf('<mxCell id=\\"b\\"')
|
|
const delta = (inputTextDelta: string) => ({
|
|
type: "tool-input-delta",
|
|
toolCallId: "d1",
|
|
inputTextDelta,
|
|
})
|
|
await slowReplies(page, [
|
|
[
|
|
sseOf(
|
|
[
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d1",
|
|
toolName: "display_diagram",
|
|
},
|
|
],
|
|
false,
|
|
),
|
|
// The first preview shows only the near shape
|
|
sseOf([delta(json.slice(0, cut))], false),
|
|
sseOf([delta(json.slice(cut))], false),
|
|
sseOf([
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: "d1",
|
|
toolName: "display_diagram",
|
|
input: { xml: near + far },
|
|
},
|
|
{ type: "finish" },
|
|
]),
|
|
],
|
|
])
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"layers.drawio",
|
|
'<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="L2" value="Notes" parent="0"/></root></mxGraphModel></diagram></mxfile>',
|
|
)
|
|
await page.waitForTimeout(1000)
|
|
await sendMessage(page, "Draw")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1500)
|
|
expect(await inView(page, "FarOne")).toBe(true)
|
|
})
|
|
|
|
test("a reply that just ends mid-drawing can be retried", async ({
|
|
page,
|
|
}) => {
|
|
const stream = streamedDisplay("d2", {
|
|
xml: boxCell("b", "Beta", 240) + boxCell("c", "Gamma", 440),
|
|
})
|
|
await slowReplies(page, [
|
|
[createMockSSEResponse(boxCell("a", "Alpha", 40), "One.")],
|
|
[stream.head],
|
|
[createMockSSEResponse(boxCell("d", "Delta", 40), "Again.")],
|
|
])
|
|
await openApp(page)
|
|
await sendMessage(page, "One")
|
|
await waitForComplete(page)
|
|
await sendMessage(page, "Two")
|
|
await expect(
|
|
page.getByText("This attempt failed", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
await page.locator('[data-testid="retry-button"]').click()
|
|
await expect(
|
|
getIframeContent(page).getByText("Delta", { exact: true }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("a chat saved while a drawing streams keeps the diagram before it", async ({
|
|
page,
|
|
}) => {
|
|
const stream = streamedDisplay("d2", {
|
|
xml: boxCell("b", "Beta", 240) + boxCell("c", "Gamma", 440),
|
|
})
|
|
await slowReplies(
|
|
page,
|
|
[
|
|
[createMockSSEResponse(boxCell("a", "Alpha", 40), "One.")],
|
|
[stream.head],
|
|
],
|
|
true,
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "One")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1500)
|
|
await sendMessage(page, "Two")
|
|
await expect(
|
|
getIframeContent(page).getByText("Beta", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
// The tab is hidden mid-answer
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, "visibilityState", {
|
|
value: "hidden",
|
|
configurable: true,
|
|
})
|
|
document.dispatchEvent(new Event("visibilitychange"))
|
|
})
|
|
await page.waitForTimeout(1500)
|
|
const saved = await savedDiagram(page)
|
|
expect(saved).toContain("Alpha")
|
|
expect(saved).not.toContain("Beta")
|
|
})
|
|
|
|
test("a streamed edit shows each finished change before the call ends", async ({
|
|
page,
|
|
}) => {
|
|
const newXml =
|
|
'<mxCell id="a" value="Omega" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>'
|
|
const input = {
|
|
operations: [
|
|
{ operation: "update", cell_id: "a", new_xml: newXml },
|
|
],
|
|
}
|
|
const json = JSON.stringify(input)
|
|
const split = json.indexOf("Omega") + 2
|
|
const end = json.length - 4
|
|
const delta = (inputTextDelta: string) => ({
|
|
type: "tool-input-delta",
|
|
toolCallId: "e1",
|
|
inputTextDelta,
|
|
})
|
|
await slowReplies(page, [
|
|
[createMockSSEResponse(boxCell("a", "Alpha", 40), "One.")],
|
|
[
|
|
sseOf(
|
|
[
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "e1",
|
|
toolName: "edit_diagram",
|
|
},
|
|
],
|
|
false,
|
|
),
|
|
sseOf([delta(json.slice(0, split))], false),
|
|
// The operation's XML is complete; the call is not
|
|
sseOf([delta(json.slice(split, end))], false),
|
|
": wait\n\n",
|
|
": wait\n\n",
|
|
": wait\n\n",
|
|
": wait\n\n",
|
|
sseOf([
|
|
delta(json.slice(end)),
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: "e1",
|
|
toolName: "edit_diagram",
|
|
input,
|
|
},
|
|
{ type: "finish" },
|
|
]),
|
|
],
|
|
])
|
|
await openApp(page)
|
|
await sendMessage(page, "One")
|
|
await waitForComplete(page)
|
|
await sendMessage(page, "Rename it")
|
|
await expect(page.getByText("Changing the diagram…")).toBeVisible({
|
|
timeout: 10000,
|
|
})
|
|
await expect(
|
|
getIframeContent(page).getByText("Omega", { exact: true }),
|
|
).toBeVisible({ timeout: 2500 })
|
|
await expect(page.getByText("Changing the diagram…")).toBeVisible()
|
|
})
|
|
|
|
test("editing a message whose diagram wasn't saved says so", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await seedSession(page, {
|
|
id: "nosnap",
|
|
title: "No snapshot",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "u1",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "No snapshot" }],
|
|
},
|
|
{
|
|
id: "a1",
|
|
role: "assistant",
|
|
parts: [{ type: "text", text: "Done." }],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: "",
|
|
versions: [],
|
|
})
|
|
await page.goto("/?session=nosnap", { waitUntil: "networkidle" })
|
|
await expect(page.getByText("Done.")).toBeVisible({ timeout: 30000 })
|
|
await page.getByText("No snapshot", { exact: true }).last().hover()
|
|
await page.getByRole("button", { name: "Edit message" }).click()
|
|
const editor = page
|
|
.locator("textarea")
|
|
.filter({ hasText: "No snapshot" })
|
|
await editor.fill("No snapshot, edited")
|
|
await editor.press("ControlOrMeta+Enter")
|
|
await expect(
|
|
page.getByText(
|
|
"This message can't be sent again: the diagram from when it was sent wasn't saved.",
|
|
),
|
|
).toBeVisible()
|
|
await expect(
|
|
page.locator("textarea").filter({ hasText: "No snapshot, edited" }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("a saved prompt sent while a link is still read keeps the draft", async ({
|
|
page,
|
|
}) => {
|
|
let releaseUrl: () => void = () => {}
|
|
await page.route("**/api/parse-url", async (route) => {
|
|
await new Promise<void>((resolve) => {
|
|
releaseUrl = resolve
|
|
})
|
|
await route.fulfill({
|
|
status: 200,
|
|
json: { title: "Spec", content: "text", charCount: 4 },
|
|
})
|
|
})
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await getChatInput(page).fill("Template prompt")
|
|
await page.getByRole("button", { name: "Add", exact: true }).click()
|
|
await page.getByText("Save this prompt").click()
|
|
await page.getByRole("button", { name: "Save prompt" }).click()
|
|
await page.waitForTimeout(500)
|
|
// A draft, and a link still being read
|
|
await getChatInput(page).fill("My draft")
|
|
await page.getByRole("button", { name: "Add", exact: true }).click()
|
|
await page.getByText("Extract from URL").click()
|
|
await page
|
|
.getByPlaceholder("https://example.com/article")
|
|
.fill("https://example.com/spec")
|
|
await page.getByRole("button", { name: "Extract", exact: true }).click()
|
|
await page.waitForTimeout(300)
|
|
await page.keyboard.press("Escape")
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await page.getByText("Template prompt").first().click()
|
|
await page.getByRole("button", { name: "Send prompt" }).click()
|
|
await expect(
|
|
page.getByText(
|
|
"Attachments are still being read. Send again when they are ready.",
|
|
),
|
|
).toBeVisible()
|
|
await expect(getChatInput(page)).toHaveValue("My draft")
|
|
releaseUrl()
|
|
})
|
|
|
|
test("a hand move made before an AI change is undone after it", async ({
|
|
page,
|
|
}) => {
|
|
await watchAutosave(page)
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
const canvas = getIframeContent(page)
|
|
await expect.poll(() => savedX(page, "start")).not.toBeNull()
|
|
const x0 = await savedX(page, "start")
|
|
await canvas.getByText("Start", { exact: true }).click()
|
|
await page.keyboard.press("Shift+ArrowRight")
|
|
await expect.poll(() => savedX(page, "start")).not.toBe(x0)
|
|
const x1 = await savedX(page, "start")
|
|
await page.waitForTimeout(1200)
|
|
await sendMessage(page, "Add a review step")
|
|
await waitForCompleteCount(page, 2)
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
await page.waitForTimeout(1500)
|
|
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.poll(() => savedX(page, "start")).toBe(x1)
|
|
await page.keyboard.press("ControlOrMeta+z")
|
|
await expect.poll(() => savedX(page, "start")).toBe(x0)
|
|
await expect(canvas.getByText("Start", { exact: true })).toHaveCount(1)
|
|
})
|
|
|
|
test("undo and redo across an AI change keep a later hand move", async ({
|
|
page,
|
|
}) => {
|
|
await watchAutosave(page)
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await drawAndEdit(page)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
await page.waitForTimeout(1500)
|
|
await expect.poll(() => savedX(page, "start")).not.toBeNull()
|
|
const x0 = await savedX(page, "start")
|
|
await canvas.getByText("Start", { exact: true }).click()
|
|
await page.keyboard.press("Shift+ArrowRight")
|
|
await expect.poll(() => savedX(page, "start")).not.toBe(x0)
|
|
const x1 = await savedX(page, "start")
|
|
await canvas
|
|
.locator(".geDiagramContainer")
|
|
.click({ position: { x: 10, y: 10 } })
|
|
await page.keyboard.press("ControlOrMeta+z")
|
|
await expect.poll(() => savedX(page, "start")).toBe(x0)
|
|
await page.keyboard.press("ControlOrMeta+z")
|
|
await expect(canvas.getByText("Review Step")).toHaveCount(0)
|
|
await page.keyboard.press("ControlOrMeta+Shift+z")
|
|
await expect(canvas.getByText("Review Step")).toBeVisible()
|
|
await page.keyboard.press("ControlOrMeta+Shift+z")
|
|
await expect.poll(() => savedX(page, "start")).toBe(x1)
|
|
})
|
|
|
|
test("a new AI drawing over a file with variables keeps them and its undo", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(
|
|
'<UserObject label="Lead %team%" placeholders="1" id="w"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="300" y="40" width="160" height="60" as="geometry"/></mxCell></UserObject>',
|
|
"Drawn.",
|
|
),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"vars.drawio",
|
|
`<mxfile vars="{"team":"Blue"}"><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject label="Team %team%" placeholders="1" id="v"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="160" height="60" as="geometry"/></mxCell></UserObject></root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Team Blue")).toBeVisible()
|
|
await page.waitForTimeout(1000)
|
|
await sendMessage(page, "Draw it again")
|
|
await waitForComplete(page)
|
|
await expect(canvas.getByText("Lead Blue")).toBeVisible()
|
|
await expect(drawioUndo(page)).not.toHaveAttribute("disabled")
|
|
})
|
|
|
|
test("Stop right as a drawing call starts shows it as stopped, never failed", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
w.__sawFailed = false
|
|
new MutationObserver(() => {
|
|
if (document.body?.innerText.includes("This attempt failed")) {
|
|
w.__sawFailed = true
|
|
}
|
|
}).observe(document, {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
})
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const realFetch = window.fetch
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
const send = (events: object[]) =>
|
|
c.enqueue(new TextEncoder().encode(sse(events)))
|
|
send([
|
|
{ type: "start" },
|
|
{ type: "text-start", id: "t1" },
|
|
{
|
|
type: "text-delta",
|
|
id: "t1",
|
|
delta: "Let me draw.",
|
|
},
|
|
])
|
|
await new Promise((r) => setTimeout(r, 1500))
|
|
send([
|
|
{ type: "text-end", id: "t1" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d1",
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-delta",
|
|
toolCallId: "d1",
|
|
inputTextDelta: '{"xml":"<mxCell',
|
|
},
|
|
])
|
|
// Stop before the next render shows the call
|
|
setTimeout(() => {
|
|
;(
|
|
document.querySelector(
|
|
'button[aria-label^="Stop"]',
|
|
) as HTMLButtonElement | null
|
|
)?.click()
|
|
}, 20)
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "Draw")
|
|
await expect(page.getByText("Let me draw.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await expect(page.getByText("Stopped", { exact: true })).toBeVisible()
|
|
await page.waitForTimeout(1000)
|
|
expect(await page.evaluate(() => (window as any).__sawFailed)).toBe(
|
|
false,
|
|
)
|
|
})
|
|
|
|
test("a chat put on screen while another one draws saves its own diagram", async ({
|
|
page,
|
|
}) => {
|
|
const stream = streamedDisplay("d3", {
|
|
xml: boxCell("p", "PrevOne", 240) + boxCell("q", "QueueOne", 440),
|
|
})
|
|
await slowReplies(
|
|
page,
|
|
[
|
|
[createMockSSEResponse(boxCell("a", "BeeOne", 40), "B.")],
|
|
[createMockSSEResponse(boxCell("a", "AyOne", 40), "A.")],
|
|
[stream.head],
|
|
],
|
|
true,
|
|
)
|
|
await openApp(page)
|
|
const chatB = await twoChats(page)
|
|
await sendMessage(page, "Chat A two")
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("PrevOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
await expect(canvas.getByText("BeeOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1000)
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, "visibilityState", {
|
|
value: "hidden",
|
|
configurable: true,
|
|
})
|
|
document.dispatchEvent(new Event("visibilitychange"))
|
|
})
|
|
await page.waitForTimeout(1500)
|
|
const saved = await savedDiagram(page, chatB ?? undefined)
|
|
expect(saved).toContain("BeeOne")
|
|
expect(saved).not.toContain("AyOne")
|
|
})
|
|
|
|
test("a reply of the chat left that ends late adds nothing to the chat on screen", async ({
|
|
page,
|
|
}) => {
|
|
await slowReplies(page, [
|
|
[createMockSSEResponse(boxCell("a", "BeeOne", 40), "B.")],
|
|
[createMockSSEResponse(boxCell("a", "AyOne", 40), "A.")],
|
|
[
|
|
sseOf(
|
|
[
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d3",
|
|
toolName: "display_diagram",
|
|
},
|
|
],
|
|
false,
|
|
),
|
|
": wait\n\n",
|
|
": wait\n\n",
|
|
": wait\n\n",
|
|
": wait\n\n",
|
|
],
|
|
])
|
|
await openApp(page)
|
|
const chatB = await twoChats(page)
|
|
await sendMessage(page, "Chat A two")
|
|
await expect(page.getByText("Drawing the diagram…")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
await expect(page.getByText("B.", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
// A's reply ends a few seconds later
|
|
await page.waitForTimeout(4500)
|
|
await expect(
|
|
page.getByText("The answer ended before the diagram was finished."),
|
|
).toHaveCount(0)
|
|
})
|
|
|
|
test("shapes taken off a message while its image is read stay off", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: (() => void)[] = []
|
|
const real = FileReader.prototype.readAsDataURL
|
|
FileReader.prototype.readAsDataURL = function (blob: Blob) {
|
|
if (!w.__holdReads) return real.call(this, blob)
|
|
held.push(() => {
|
|
Object.defineProperty(this, "error", {
|
|
value: new DOMException("gone", "NotReadableError"),
|
|
})
|
|
this.onerror?.(new ProgressEvent("error") as any)
|
|
})
|
|
}
|
|
w.__failReads = () => {
|
|
w.__holdReads = false
|
|
for (const go of held.splice(0)) go()
|
|
}
|
|
})
|
|
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()
|
|
const chip = page.locator('[data-testid="selection-chip"]')
|
|
await expect(chip).toContainText("Process")
|
|
await getAttachmentInput(page).setInputFiles({
|
|
name: "img.png",
|
|
mimeType: "image/png",
|
|
buffer: ONE_PIXEL_PNG,
|
|
})
|
|
await page.waitForTimeout(500)
|
|
await page.evaluate(() => {
|
|
;(window as any).__holdReads = true
|
|
})
|
|
await sendMessage(page, "Make this red")
|
|
await page.waitForTimeout(300)
|
|
// The user takes the shapes off while the image is read
|
|
await chip.getByRole("button").click()
|
|
await expect(chip).toHaveCount(0)
|
|
await page.evaluate(() => (window as any).__failReads())
|
|
await expect(
|
|
page
|
|
.getByRole("region", { name: /Notifications/ })
|
|
.getByText("Failed to read file: img.png"),
|
|
).toBeVisible()
|
|
await page.waitForTimeout(500)
|
|
await expect(chip).toHaveCount(0)
|
|
})
|
|
|
|
test("a saved prompt sent while the diagram exports does not lose the message", async ({
|
|
page,
|
|
}) => {
|
|
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", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await getChatInput(page).fill("Template prompt")
|
|
await page.getByRole("button", { name: "Add", exact: true }).click()
|
|
await page.getByText("Save this prompt").click()
|
|
await page.getByRole("button", { name: "Save prompt" }).click()
|
|
await page.waitForTimeout(500)
|
|
await page.evaluate(() => {
|
|
;(window as any).__dropExports = true
|
|
})
|
|
await sendMessage(page, "My message")
|
|
await page.waitForTimeout(300)
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await page.getByText("Template prompt").first().click()
|
|
// The export times out: the message comes back
|
|
await expect(getChatInput(page)).toHaveValue("My message", {
|
|
timeout: 20000,
|
|
})
|
|
})
|
|
|
|
test("a reply of the chat left that ends late starts no request in the chat on screen", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const realFetch = window.fetch
|
|
;(window as any).__requests = 0
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const n = ++(window as any).__requests
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
if (n > 1) {
|
|
c.enqueue(
|
|
new TextEncoder().encode(
|
|
sse([
|
|
{ type: "start" },
|
|
{ type: "finish" },
|
|
]),
|
|
),
|
|
)
|
|
c.close()
|
|
return
|
|
}
|
|
c.enqueue(
|
|
new TextEncoder().encode(
|
|
sse([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d9",
|
|
toolName: "display_diagram",
|
|
},
|
|
]),
|
|
),
|
|
)
|
|
await new Promise((r) => setTimeout(r, 5000))
|
|
c.close()
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
// Chat B's last answer ended with a failed drawing (retries used up)
|
|
await seedSession(page, {
|
|
id: "berr",
|
|
title: "Chat B",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "bu",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Chat B" }],
|
|
},
|
|
{
|
|
id: "ba",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "tool-display_diagram",
|
|
toolCallId: "b1",
|
|
state: "output-error",
|
|
input: { xml: "<mxCell" },
|
|
errorText: "XML validation failed",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
xmlSnapshots: [[0, doc]],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
await sendMessage(page, "Chat A one")
|
|
await expect(page.getByText("Drawing the diagram…")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=berr")
|
|
})
|
|
await expect(
|
|
getIframeContent(page).getByText("BeeOne", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
// A's reply ends meanwhile
|
|
await page.waitForTimeout(7000)
|
|
expect(await page.evaluate(() => (window as any).__requests)).toBe(1)
|
|
})
|
|
|
|
test("shapes taken off while an image is read stay off after the export fails", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: (() => void)[] = []
|
|
const real = FileReader.prototype.readAsDataURL
|
|
FileReader.prototype.readAsDataURL = function (blob: Blob) {
|
|
if (!w.__holdReads) return real.call(this, blob)
|
|
held.push(() => real.call(this, blob))
|
|
}
|
|
w.__releaseReads = () => {
|
|
w.__holdReads = false
|
|
for (const go of held.splice(0)) go()
|
|
}
|
|
window.addEventListener("message", (event) => {
|
|
if (
|
|
w.__dropExports &&
|
|
typeof event.data === "string" &&
|
|
event.data.includes('"event":"export"')
|
|
) {
|
|
event.stopImmediatePropagation()
|
|
}
|
|
})
|
|
})
|
|
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()
|
|
const chip = page.locator('[data-testid="selection-chip"]')
|
|
await expect(chip).toContainText("Process")
|
|
await getAttachmentInput(page).setInputFiles({
|
|
name: "img.png",
|
|
mimeType: "image/png",
|
|
buffer: ONE_PIXEL_PNG,
|
|
})
|
|
await page.waitForTimeout(500)
|
|
await page.evaluate(() => {
|
|
;(window as any).__holdReads = true
|
|
;(window as any).__dropExports = true
|
|
})
|
|
await sendMessage(page, "Make this red")
|
|
await page.waitForTimeout(300)
|
|
await chip.getByRole("button").click()
|
|
await expect(chip).toHaveCount(0)
|
|
await page.evaluate(() => (window as any).__releaseReads())
|
|
// The export times out and the message comes back, without them
|
|
await expect(getChatInput(page)).toHaveValue("Make this red", {
|
|
timeout: 20000,
|
|
})
|
|
await page.waitForTimeout(500)
|
|
await expect(chip).toHaveCount(0)
|
|
})
|
|
|
|
test("page ids: written ones keep their pages, ones draw.io can't take are replaced", async ({
|
|
page,
|
|
}) => {
|
|
const pageXml = (attrs: string, label: string) =>
|
|
`<diagram ${attrs}><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("x", label, 40)}</root></mxGraphModel></diagram>`
|
|
const savedIds = () =>
|
|
savedDiagram(page).then((xml) =>
|
|
Array.from(xml.matchAll(/<diagram\b([^>]*)>/g), ([, attrs]) => {
|
|
const name = /\bname="([^"]*)"/.exec(attrs)?.[1]
|
|
const id = /\bid="([^"]*)"/.exec(attrs)?.[1]
|
|
return `${name}=${id}`
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
// Page A has no id, so draw.io would number it "0", B's id
|
|
await openDrawioFile(
|
|
page,
|
|
"ids.drawio",
|
|
`<mxfile>${pageXml('name="A"', "PageA")}${pageXml('name="B" id="0"', "PageB")}</mxfile>`,
|
|
)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("PageA", { exact: true })).toBeVisible()
|
|
await expect.poll(savedIds).toContain("B=0")
|
|
await openDrawioFile(
|
|
page,
|
|
"ctor.drawio",
|
|
`<mxfile>${pageXml('name="C" id="constructor"', "PageC")}${pageXml('name="D" id="constructor"', "PageD")}</mxfile>`,
|
|
)
|
|
await expect(canvas.getByText("PageC", { exact: true })).toBeVisible()
|
|
})
|
|
|
|
test("Stop in one chat does not label another chat's cut off drawing", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const realFetch = window.fetch
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const body = new ReadableStream({
|
|
start(c) {
|
|
c.enqueue(
|
|
new TextEncoder().encode(
|
|
sse([
|
|
{ type: "start" },
|
|
{ type: "text-start", id: "t1" },
|
|
{
|
|
type: "text-delta",
|
|
id: "t1",
|
|
delta: "Thinking about it.",
|
|
},
|
|
]),
|
|
),
|
|
)
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
// Chat B was saved while a drawing streamed (its tab was closed)
|
|
await seedSession(page, {
|
|
id: "bcut",
|
|
title: "Chat B",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "bu",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Chat B" }],
|
|
},
|
|
{
|
|
id: "ba",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "tool-display_diagram",
|
|
toolCallId: "b1",
|
|
state: "input-streaming",
|
|
input: { xml: "<mxCell" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
xmlSnapshots: [[0, doc]],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
await sendMessage(page, "Chat A one")
|
|
await expect(page.getByText("Thinking about it.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.getByRole("button", { name: "Stop" }).click()
|
|
await page.waitForTimeout(1500)
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=bcut")
|
|
})
|
|
await expect(
|
|
getIframeContent(page).getByText("BeeOne", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
await expect(
|
|
page.getByText("This attempt failed", { exact: true }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("a drawing of the chat left that ends late leaves the canvas on screen as it was", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const cell = (id: string, label: string, x: number) =>
|
|
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
|
const realFetch = window.fetch
|
|
let n = 0
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const i = n++
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
const send = (events: object[]) =>
|
|
c.enqueue(new TextEncoder().encode(sse(events)))
|
|
if (i < 2) {
|
|
send([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: `x${i}`,
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: `x${i}`,
|
|
toolName: "display_diagram",
|
|
input: {
|
|
xml: cell(
|
|
"a",
|
|
i === 0 ? "BeeOne" : "AyOne",
|
|
40,
|
|
),
|
|
},
|
|
},
|
|
{ type: "finish" },
|
|
])
|
|
c.close()
|
|
return
|
|
}
|
|
send([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d3",
|
|
toolName: "display_diagram",
|
|
},
|
|
])
|
|
// The chat is left meanwhile; then the drawing streams on
|
|
await new Promise((r) => setTimeout(r, 4000))
|
|
const json = JSON.stringify({
|
|
xml:
|
|
cell("p", "PrevOne", 240) +
|
|
cell("q", "QueueOne", 440),
|
|
})
|
|
send([
|
|
{
|
|
type: "tool-input-delta",
|
|
toolCallId: "d3",
|
|
inputTextDelta: json.slice(0, json.length - 30),
|
|
},
|
|
])
|
|
await new Promise((r) => setTimeout(r, 2000))
|
|
c.close()
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
await openApp(page)
|
|
const chatB = await twoChats(page)
|
|
await sendMessage(page, "Chat A two")
|
|
await expect(page.getByText("Drawing the diagram…")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("BeeOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(8000)
|
|
await expect(canvas.getByText("PrevOne", { exact: true })).toHaveCount(
|
|
0,
|
|
)
|
|
await expect(canvas.getByText("BeeOne", { exact: true })).toBeVisible()
|
|
})
|
|
|
|
test("a saved prompt sent while a message is prepared says why nothing happens", async ({
|
|
page,
|
|
}) => {
|
|
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", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await getChatInput(page).fill("Template prompt")
|
|
await page.getByRole("button", { name: "Add", exact: true }).click()
|
|
await page.getByText("Save this prompt").click()
|
|
await page.getByRole("button", { name: "Save prompt" }).click()
|
|
await page.waitForTimeout(500)
|
|
await page.evaluate(() => {
|
|
;(window as any).__dropExports = true
|
|
})
|
|
await sendMessage(page, "My message")
|
|
await page.waitForTimeout(300)
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await page.getByText("Template prompt").first().click()
|
|
await expect(
|
|
page
|
|
.getByRole("region", { name: /Notifications/ })
|
|
.getByText(
|
|
"The last message is still being sent. Try again in a moment.",
|
|
),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("an example drawn into a blank file with variables keeps its undo", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await openDrawioFile(
|
|
page,
|
|
"vars.drawio",
|
|
'<mxfile vars="{"team":"Blue"}"><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
|
|
)
|
|
await page.waitForTimeout(1000)
|
|
// The cached example's prompt: drawn without a request
|
|
await sendMessage(page, "Draw a cat for me")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1000)
|
|
await expect(drawioUndo(page)).not.toHaveAttribute("disabled")
|
|
})
|
|
|
|
test("shapes deselected while the message exports can be picked again for the next one", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: MessageEvent[] = []
|
|
w.__releaseExports = () => {
|
|
w.__holdExports = false
|
|
for (const e of held.splice(0)) {
|
|
const copy = new MessageEvent("message", {
|
|
data: e.data,
|
|
origin: e.origin,
|
|
source: e.source,
|
|
})
|
|
;(copy as any).__released = true
|
|
window.dispatchEvent(copy)
|
|
}
|
|
}
|
|
window.addEventListener("message", (event) => {
|
|
if (
|
|
w.__holdExports &&
|
|
!(event as any).__released &&
|
|
typeof event.data === "string" &&
|
|
event.data.includes('"event":"export"')
|
|
) {
|
|
event.stopImmediatePropagation()
|
|
held.push(event)
|
|
}
|
|
})
|
|
})
|
|
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 canvas = getIframeContent(page)
|
|
const shape = canvas.getByText("Process", { exact: true })
|
|
const chip = page.locator('[data-testid="selection-chip"]')
|
|
await shape.click()
|
|
await expect(chip).toContainText("Process")
|
|
await page.evaluate(() => {
|
|
;(window as any).__holdExports = true
|
|
})
|
|
await sendMessage(page, "Make this red")
|
|
// Deselected while the diagram exports
|
|
await canvas
|
|
.locator(".geDiagramContainer")
|
|
.click({ position: { x: 10, y: 10 } })
|
|
await expect(chip).toHaveCount(0)
|
|
await page.evaluate(() => (window as any).__releaseExports())
|
|
await waitForCompleteCount(page, 2)
|
|
// Picked again for a follow-up
|
|
await shape.click()
|
|
await expect(chip).toContainText("Process")
|
|
})
|
|
|
|
test("Enter while the last message is still being sent says so; a saved prompt then is not counted", async ({
|
|
page,
|
|
}) => {
|
|
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", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await getChatInput(page).fill("Template prompt")
|
|
await page.getByRole("button", { name: "Add", exact: true }).click()
|
|
await page.getByText("Save this prompt").click()
|
|
await page.getByRole("button", { name: "Save prompt" }).click()
|
|
await page.waitForTimeout(500)
|
|
await page.evaluate(() => {
|
|
;(window as any).__dropExports = true
|
|
})
|
|
await sendMessage(page, "My message")
|
|
await page.waitForTimeout(300)
|
|
const notice = page
|
|
.getByRole("region", { name: /Notifications/ })
|
|
.getByText(
|
|
"The last message is still being sent. Try again in a moment.",
|
|
)
|
|
await sendMessage(page, "More words")
|
|
await expect(notice).toBeVisible()
|
|
await expect(getChatInput(page)).toHaveValue("More words")
|
|
await getChatInput(page).fill("")
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await page.getByText("Template prompt").first().click()
|
|
await page.waitForTimeout(500)
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await expect(
|
|
page.getByRole("dialog").getByText("Not used yet"),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("a saved prompt whose own send fails is not counted as used", async ({
|
|
page,
|
|
}) => {
|
|
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(boxCell("a", "Alpha", 40), "Here."),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await saveTemplate(page)
|
|
await page.evaluate(() => {
|
|
;(window as any).__dropExports = true
|
|
})
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await page.getByText("Template prompt").first().click()
|
|
// Its export times out: the prompt comes back to the input
|
|
await expect(getChatInput(page)).toHaveValue("Template prompt", {
|
|
timeout: 20000,
|
|
})
|
|
await page.waitForTimeout(500)
|
|
await getChatInput(page).fill("")
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await expect(
|
|
page.getByRole("dialog").getByText("Not used yet"),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("a saved prompt clicked as the last message goes out does not start a second answer", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: MessageEvent[] = []
|
|
w.__releaseExports = () => {
|
|
w.__holdExports = false
|
|
for (const e of held.splice(0)) {
|
|
const copy = new MessageEvent("message", {
|
|
data: e.data,
|
|
origin: e.origin,
|
|
source: e.source,
|
|
})
|
|
;(copy as any).__released = true
|
|
window.dispatchEvent(copy)
|
|
}
|
|
}
|
|
window.addEventListener("message", (event) => {
|
|
if (
|
|
w.__holdExports &&
|
|
!(event as any).__released &&
|
|
typeof event.data === "string" &&
|
|
event.data.includes('"event":"export"')
|
|
) {
|
|
event.stopImmediatePropagation()
|
|
held.push(event)
|
|
}
|
|
})
|
|
// Keeps the saved prompts' store busy, so the panel's click count waits
|
|
w.__blockTemplates = () =>
|
|
new Promise<void>((started) => {
|
|
const request = indexedDB.open("next-ai-drawio-templates")
|
|
request.onsuccess = () => {
|
|
const db = request.result
|
|
const tx = db.transaction("templates", "readwrite")
|
|
const store = tx.objectStore("templates")
|
|
w.__blocking = true
|
|
const spin = () => {
|
|
if (!w.__blocking) return
|
|
store.get("none").onsuccess = spin
|
|
}
|
|
spin()
|
|
started()
|
|
}
|
|
})
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const realFetch = window.fetch
|
|
w.__requests = 0
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const n = ++w.__requests
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
c.enqueue(
|
|
new TextEncoder().encode(
|
|
sse([
|
|
{ type: "start" },
|
|
{ type: "text-start", id: `t${n}` },
|
|
{
|
|
type: "text-delta",
|
|
id: `t${n}`,
|
|
delta: `Answer ${n}.`,
|
|
},
|
|
]),
|
|
),
|
|
)
|
|
await new Promise((r) =>
|
|
setTimeout(r, n === 1 ? 100 : 6000),
|
|
)
|
|
c.enqueue(
|
|
new TextEncoder().encode(
|
|
sse([
|
|
{ type: "text-end", id: `t${n}` },
|
|
{ type: "finish" },
|
|
]) + "data: [DONE]\n\n",
|
|
),
|
|
)
|
|
c.close()
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await expect(page.getByText("Answer 1.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1000)
|
|
await saveTemplate(page)
|
|
await page.evaluate(() => {
|
|
;(window as any).__holdExports = true
|
|
})
|
|
await sendMessage(page, "Second")
|
|
await page.waitForTimeout(300)
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await expect(page.getByText("Template prompt").first()).toBeVisible()
|
|
await page.evaluate(() => (window as any).__blockTemplates())
|
|
await page.getByText("Template prompt").first().click()
|
|
await page.waitForTimeout(300)
|
|
// The second message goes out and its answer starts; then the store frees up
|
|
await page.evaluate(() => (window as any).__releaseExports())
|
|
await expect(page.getByText("Answer 2.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate(() => {
|
|
;(window as any).__blocking = false
|
|
})
|
|
await page.waitForTimeout(2500)
|
|
const requests = await page.evaluate(() => (window as any).__requests)
|
|
expect(requests).toBe(2)
|
|
})
|
|
|
|
test("going back to another language's page mid-drawing sends nothing more", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const realFetch = window.fetch
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const count =
|
|
Number(localStorage.getItem("__chatRequests") ?? "0") + 1
|
|
localStorage.setItem("__chatRequests", String(count))
|
|
const body = new ReadableStream({
|
|
start(c) {
|
|
c.enqueue(
|
|
new TextEncoder().encode(
|
|
sse([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: `d${count}`,
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-delta",
|
|
toolCallId: `d${count}`,
|
|
inputTextDelta: '{"xml":"<mxCell',
|
|
},
|
|
]),
|
|
),
|
|
)
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
await openApp(page)
|
|
await openSettingsTab(page, "appearance")
|
|
await page.locator("#language-select").click()
|
|
await page.getByRole("option", { name: "日本語", exact: true }).click()
|
|
await expect(page).toHaveURL(/\/ja/, { timeout: 15000 })
|
|
await page.keyboard.press("Escape")
|
|
await expect(
|
|
page.locator('[data-testid="canvas-loading"]'),
|
|
).toHaveCount(0, { timeout: 30000 })
|
|
await sendMessage(page, "Draw")
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => localStorage.getItem("__chatRequests")),
|
|
)
|
|
.toBe("1")
|
|
await page.waitForTimeout(1000)
|
|
await page.goBack()
|
|
await page.waitForTimeout(5000)
|
|
const count = await page.evaluate(() =>
|
|
localStorage.getItem("__chatRequests"),
|
|
)
|
|
expect(count).toBe("1")
|
|
})
|
|
|
|
test("a saved prompt picked while an answer comes in says why it is not sent", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: MessageEvent[] = []
|
|
w.__releaseExports = () => {
|
|
w.__holdExports = false
|
|
for (const e of held.splice(0)) {
|
|
const copy = new MessageEvent("message", {
|
|
data: e.data,
|
|
origin: e.origin,
|
|
source: e.source,
|
|
})
|
|
;(copy as any).__released = true
|
|
window.dispatchEvent(copy)
|
|
}
|
|
}
|
|
window.addEventListener("message", (event) => {
|
|
if (
|
|
w.__holdExports &&
|
|
!(event as any).__released &&
|
|
typeof event.data === "string" &&
|
|
event.data.includes('"event":"export"')
|
|
) {
|
|
event.stopImmediatePropagation()
|
|
held.push(event)
|
|
}
|
|
})
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const realFetch = window.fetch
|
|
let n = 0
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const i = ++n
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
c.enqueue(
|
|
new TextEncoder().encode(
|
|
sse([
|
|
{ type: "start" },
|
|
{ type: "text-start", id: `t${i}` },
|
|
{
|
|
type: "text-delta",
|
|
id: `t${i}`,
|
|
delta: `Answer ${i}.`,
|
|
},
|
|
]),
|
|
),
|
|
)
|
|
await new Promise((r) =>
|
|
setTimeout(r, i === 1 ? 100 : 8000),
|
|
)
|
|
c.enqueue(
|
|
new TextEncoder().encode(
|
|
sse([
|
|
{ type: "text-end", id: `t${i}` },
|
|
{ type: "finish" },
|
|
]) + "data: [DONE]\n\n",
|
|
),
|
|
)
|
|
c.close()
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await expect(page.getByText("Answer 1.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1000)
|
|
await saveTemplate(page)
|
|
await page.evaluate(() => {
|
|
;(window as any).__holdExports = true
|
|
})
|
|
await sendMessage(page, "Second")
|
|
await page.waitForTimeout(300)
|
|
// The list is open when the answer starts
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await expect(page.getByText("Template prompt").first()).toBeVisible()
|
|
await page.evaluate(() => (window as any).__releaseExports())
|
|
await expect(page.getByText("Answer 2.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.getByText("Template prompt").first().click()
|
|
await expect(
|
|
page
|
|
.getByRole("region", { name: /Notifications/ })
|
|
.getByText(
|
|
"An answer is still coming. Send again when it is done.",
|
|
),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("a saved prompt goes out at once, also while its click count waits for storage", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
// Keeps the saved prompts' store busy, so its counts wait
|
|
w.__blockTemplates = () =>
|
|
new Promise<void>((started) => {
|
|
const request = indexedDB.open("next-ai-drawio-templates")
|
|
request.onsuccess = () => {
|
|
const tx = request.result.transaction(
|
|
"templates",
|
|
"readwrite",
|
|
)
|
|
const store = tx.objectStore("templates")
|
|
w.__blocking = true
|
|
const spin = () => {
|
|
if (!w.__blocking) return
|
|
store.get("none").onsuccess = spin
|
|
}
|
|
spin()
|
|
started()
|
|
}
|
|
})
|
|
})
|
|
const prompts = await recordPrompts(page)
|
|
await openApp(page)
|
|
await sendMessage(page, "First")
|
|
await waitForComplete(page)
|
|
await saveTemplate(page)
|
|
await page.getByRole("button", { name: "Saved prompts" }).click()
|
|
await expect(page.getByText("Template prompt").first()).toBeVisible()
|
|
await page.evaluate(() => (window as any).__blockTemplates())
|
|
await page.getByText("Template prompt").first().click()
|
|
await expect.poll(() => prompts.length, { timeout: 10000 }).toBe(2)
|
|
expect(prompts[1]).toBe("Template prompt")
|
|
await page.evaluate(() => {
|
|
;(window as any).__blocking = false
|
|
})
|
|
})
|
|
|
|
test("a chat opened from its link shows its cut off drawing as failed", async ({
|
|
page,
|
|
}) => {
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
await seedSession(page, {
|
|
id: "cut",
|
|
title: "Cut chat",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "cu",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Cut chat" }],
|
|
},
|
|
{
|
|
id: "ca",
|
|
role: "assistant",
|
|
parts: [
|
|
{
|
|
type: "tool-display_diagram",
|
|
toolCallId: "c1",
|
|
state: "input-streaming",
|
|
input: { xml: "<mxCell" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
xmlSnapshots: [[0, doc]],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
await page.goto("/?session=cut", { waitUntil: "networkidle" })
|
|
await expect(
|
|
getIframeContent(page).getByText("BeeOne", { exact: true }),
|
|
).toBeVisible({ timeout: 30000 })
|
|
await expect(
|
|
page.getByText("This attempt failed", { exact: true }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("an answer left by browser back stops and is not written into the chat on screen", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const cell = (id: string, label: string, x: number) =>
|
|
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
|
const realFetch = window.fetch
|
|
let n = 0
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const i = n++
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
const send = (events: object[]) =>
|
|
c.enqueue(new TextEncoder().encode(sse(events)))
|
|
if (i < 2) {
|
|
send([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: `x${i}`,
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: `x${i}`,
|
|
toolName: "display_diagram",
|
|
input: {
|
|
xml: cell(
|
|
"a",
|
|
i === 0 ? "BeeOne" : "AyOne",
|
|
40,
|
|
),
|
|
},
|
|
},
|
|
{ type: "finish" },
|
|
])
|
|
c.close()
|
|
return
|
|
}
|
|
send([
|
|
{ type: "start" },
|
|
{ type: "text-start", id: "t3" },
|
|
{
|
|
type: "text-delta",
|
|
id: "t3",
|
|
delta: "Drawing it now.",
|
|
},
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d3",
|
|
toolName: "display_diagram",
|
|
},
|
|
])
|
|
// The chat is left meanwhile; then the answer goes on
|
|
await new Promise((r) => setTimeout(r, 4000))
|
|
const json = JSON.stringify({
|
|
xml:
|
|
cell("p", "PrevOne", 240) +
|
|
cell("q", "QueueOne", 440),
|
|
})
|
|
send([
|
|
{
|
|
type: "tool-input-delta",
|
|
toolCallId: "d3",
|
|
inputTextDelta: json.slice(0, json.length - 30),
|
|
},
|
|
])
|
|
await new Promise((r) => setTimeout(r, 2000))
|
|
c.close()
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
await openApp(page)
|
|
const chatB = await twoChats(page)
|
|
await sendMessage(page, "Chat A two")
|
|
await expect(page.getByText("Drawing it now.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("BeeOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
// Chat B is ready for a new message at once
|
|
await expect(getSendButton(page)).toBeVisible()
|
|
await page.waitForTimeout(7000)
|
|
const panel = page.locator('[data-testid="chat-panel"]')
|
|
await expect(panel.getByText("Drawing it now.")).toHaveCount(0)
|
|
await expect(canvas.getByText("PrevOne", { exact: true })).toHaveCount(
|
|
0,
|
|
)
|
|
// Saved as it was: its own two messages
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, "visibilityState", {
|
|
value: "hidden",
|
|
configurable: true,
|
|
})
|
|
document.dispatchEvent(new Event("visibilitychange"))
|
|
})
|
|
await page.waitForTimeout(1500)
|
|
const saved = await page.evaluate(async (id) => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const session: any = await new Promise((resolve) => {
|
|
const request = db
|
|
.transaction("sessions")
|
|
.objectStore("sessions")
|
|
.get(id as string)
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
db.close()
|
|
return session.messages.length as number
|
|
}, chatB)
|
|
expect(saved).toBe(2)
|
|
})
|
|
|
|
test("two drawings in one reply, the first loaded in full, end with the second on the canvas", async ({
|
|
page,
|
|
}) => {
|
|
const first = `<mxGraphModel math="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("a", "FirstOne", 40)}</root></mxGraphModel>`
|
|
const body = sseOf([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d1",
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: "d1",
|
|
toolName: "display_diagram",
|
|
input: { xml: first },
|
|
},
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "d2",
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: "d2",
|
|
toolName: "display_diagram",
|
|
input: { xml: boxCell("b", "SecondOne", 240) },
|
|
},
|
|
{ type: "finish" },
|
|
])
|
|
let n = 0
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body:
|
|
n++ === 0
|
|
? body
|
|
: sseOf([{ type: "start" }, { type: "finish" }]),
|
|
}),
|
|
)
|
|
await openApp(page)
|
|
await sendMessage(page, "Draw")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(2000)
|
|
const canvas = getIframeContent(page)
|
|
// The last call's diagram is the one on the canvas
|
|
await expect(
|
|
canvas.getByText("SecondOne", { exact: true }),
|
|
).toBeVisible()
|
|
await expect(canvas.getByText("FirstOne", { exact: true })).toHaveCount(
|
|
0,
|
|
)
|
|
})
|
|
|
|
test("after leaving an answering chat by browser back, hand edits in the chat on screen are saved", async ({
|
|
page,
|
|
}) => {
|
|
await watchAutosave(page)
|
|
await page.addInitScript(() => {
|
|
const sse = (events: object[]) =>
|
|
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
|
const cell = (id: string, label: string, x: number) =>
|
|
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
|
const realFetch = window.fetch
|
|
let n = 0
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const i = n++
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
const send = (events: object[]) =>
|
|
c.enqueue(new TextEncoder().encode(sse(events)))
|
|
if (i < 2) {
|
|
send([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: `x${i}`,
|
|
toolName: "display_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: `x${i}`,
|
|
toolName: "display_diagram",
|
|
input: {
|
|
xml: cell(
|
|
"start",
|
|
i === 0 ? "BeeOne" : "AyOne",
|
|
40,
|
|
),
|
|
},
|
|
},
|
|
{ type: "finish" },
|
|
])
|
|
c.close()
|
|
return
|
|
}
|
|
// An edit streams on and on
|
|
send([
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "e3",
|
|
toolName: "edit_diagram",
|
|
},
|
|
{
|
|
type: "tool-input-delta",
|
|
toolCallId: "e3",
|
|
inputTextDelta:
|
|
'{"operations":[{"operation":"update","cell_id":"start","new_xml":"',
|
|
},
|
|
])
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
await openApp(page)
|
|
const chatB = await twoChats(page)
|
|
await sendMessage(page, "Chat A two")
|
|
await expect(page.getByText("Changing the diagram…")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("BeeOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1500)
|
|
// A hand move in chat B
|
|
await expect.poll(() => savedX(page, "start")).not.toBeNull()
|
|
const x0 = await savedX(page, "start")
|
|
await canvas.getByText("BeeOne", { exact: true }).click()
|
|
await page.keyboard.press("Shift+ArrowRight")
|
|
await expect.poll(() => savedX(page, "start")).not.toBe(x0)
|
|
const x1 = await savedX(page, "start")
|
|
await page.waitForTimeout(2500)
|
|
const saved = await savedDiagram(page, chatB ?? undefined)
|
|
const savedXValue =
|
|
/<mxCell id="start"[\s\S]*?<mxGeometry[^>]*\bx="([^"]*)"/.exec(
|
|
saved,
|
|
)?.[1]
|
|
expect(savedXValue).toBe(x1)
|
|
})
|
|
|
|
test("a chat left by browser back mid-answer keeps its last message and the answer so far", async ({
|
|
page,
|
|
}) => {
|
|
await answerWithPause(page)
|
|
await openApp(page)
|
|
const chatB = await twoChats(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
const chatA = new URL(page.url()).searchParams.get("session")
|
|
await sendMessage(page, "Chat A two")
|
|
await expect(page.getByText("A is answering.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("Chat B", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await expect(page).toHaveURL(new RegExp(`session=${chatB}`))
|
|
// Back to chat A
|
|
await page.waitForTimeout(1000)
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatA)
|
|
const panel = page.locator('[data-testid="chat-panel"]')
|
|
await expect(panel.getByText("Chat A two")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await expect(panel.getByText("A is answering.")).toBeVisible()
|
|
// Its unfinished drawing was stopped by leaving
|
|
await expect(panel.getByText("Stopped", { exact: true })).toBeVisible()
|
|
await expect(
|
|
panel.getByText("This attempt failed", { exact: true }),
|
|
).toHaveCount(0)
|
|
})
|
|
|
|
test("a new chat left by browser back during its first answer is saved", async ({
|
|
page,
|
|
}) => {
|
|
await answerWithPause(page)
|
|
await openApp(page)
|
|
await sendMessage(page, "Chat B")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
const chatB = new URL(page.url()).searchParams.get("session")
|
|
await page.waitForTimeout(1200)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: "New diagram" })
|
|
.click()
|
|
await expect(page).not.toHaveURL(/session=/)
|
|
await sendMessage(page, "Chat A two")
|
|
await expect(page.getByText("A is answering.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatB)
|
|
await expect(
|
|
getIframeContent(page).getByText("Chat B", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
await page.waitForTimeout(1500)
|
|
// The address stays the one gone back to
|
|
await expect(page).toHaveURL(new RegExp(`session=${chatB}`))
|
|
const chats = await savedChats(page)
|
|
const chatA = chats.find((c) => c.texts.includes("Chat A two"))
|
|
expect(chatA?.texts).toEqual(["Chat A two", "A is answering."])
|
|
expect(chats.find((c) => c.id === chatB)?.texts).toEqual([
|
|
"Chat B",
|
|
"Answer to Chat B.",
|
|
])
|
|
})
|
|
|
|
test("a drawing already on the canvas of a chat left by browser back is saved as done", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem(
|
|
"next-ai-draw-io-vlm-validation-enabled",
|
|
"true",
|
|
)
|
|
})
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(boxCell("a", "AyOne", 40), "A."),
|
|
}),
|
|
)
|
|
// The screenshot check never answers: the call waits for it
|
|
await page.route("**/api/validate-diagram", () => {})
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("b", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
await seedSession(page, {
|
|
id: "bee",
|
|
title: "Chat B",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "bu",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Chat B" }],
|
|
},
|
|
{
|
|
id: "ba",
|
|
role: "assistant",
|
|
parts: [{ type: "text", text: "B." }],
|
|
},
|
|
],
|
|
xmlSnapshots: [[0, doc]],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
await sendMessage(page, "Chat A two")
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("AyOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(500)
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=bee")
|
|
})
|
|
await expect(canvas.getByText("BeeOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1000)
|
|
const chatA = (await savedChats(page)).find((c) =>
|
|
c.texts.includes("Chat A two"),
|
|
)
|
|
expect(chatA).toBeTruthy()
|
|
await page.evaluate((id) => {
|
|
;(window as any).next.router.replace(`?session=${id}`)
|
|
}, chatA?.id)
|
|
await expect(canvas.getByText("AyOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
const panel = page.locator('[data-testid="chat-panel"]')
|
|
await expect(panel.locator('[data-testid="version-card"]')).toHaveCount(
|
|
1,
|
|
)
|
|
await expect(panel.getByText("Stopped", { exact: true })).toHaveCount(0)
|
|
})
|
|
|
|
test("at the chat limit, saving a chat left by browser back keeps the chat gone back to", async ({
|
|
page,
|
|
}) => {
|
|
await answerWithPause(page)
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("b", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
// 50 saved chats; the one gone back to is the oldest
|
|
await page.evaluate(
|
|
async ({ doc }) => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
const store = tx.objectStore("sessions")
|
|
for (let i = 0; i < 50; i++) {
|
|
store.put({
|
|
id: i === 0 ? "oldest" : `chat-${i}`,
|
|
title: `Chat ${i}`,
|
|
createdAt: 1000 + i,
|
|
updatedAt: 1000 + i,
|
|
messages: [
|
|
{
|
|
id: `u${i}`,
|
|
role: "user",
|
|
parts: [
|
|
{ type: "text", text: `Chat ${i}` },
|
|
],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
}
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
},
|
|
{ doc },
|
|
)
|
|
await sendMessage(page, "Chat A two")
|
|
await expect(page.getByText("A is answering.")).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=oldest")
|
|
})
|
|
await expect(
|
|
getIframeContent(page).getByText("BeeOne", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
// Kept from the start, not saved again after being deleted
|
|
expect((await savedChats(page)).map((c) => c.id)).toContain("oldest")
|
|
await page.waitForTimeout(1500)
|
|
const ids = (await savedChats(page)).map((c) => c.id)
|
|
expect(ids).toContain("oldest")
|
|
expect(ids).toHaveLength(50)
|
|
})
|
|
|
|
test("a chat left by browser back while a cut off drawing waits for its continuation keeps the diagram from before it", async ({
|
|
page,
|
|
}) => {
|
|
// Cut off in the middle of its second shape
|
|
const cut = `${boxCell("p", "PartOne", 240)}<mxCell id="q" value="Cut" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="440"`
|
|
const deltas = (
|
|
JSON.stringify({ xml: cut }).match(/[\s\S]{1,30}/g) ?? []
|
|
).map((d) => ({
|
|
type: "tool-input-delta",
|
|
toolCallId: "cut",
|
|
inputTextDelta: d,
|
|
}))
|
|
await slowReplies(
|
|
page,
|
|
[
|
|
[createMockSSEResponse(boxCell("a", "AyOne", 40), "A.")],
|
|
[
|
|
sseOf(
|
|
[
|
|
{ type: "start" },
|
|
{
|
|
type: "tool-input-start",
|
|
toolCallId: "cut",
|
|
toolName: "display_diagram",
|
|
},
|
|
...deltas,
|
|
],
|
|
false,
|
|
),
|
|
sseOf([
|
|
{
|
|
type: "tool-input-available",
|
|
toolCallId: "cut",
|
|
toolName: "display_diagram",
|
|
input: { xml: cut },
|
|
},
|
|
{ type: "finish" },
|
|
]),
|
|
],
|
|
// The continuation never comes
|
|
[sseOf([{ type: "start" }], false)],
|
|
],
|
|
true,
|
|
)
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("b", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
await seedSession(page, {
|
|
id: "bee",
|
|
title: "Chat B",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "bu",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Chat B" }],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
await sendMessage(page, "Chat A one")
|
|
await waitForComplete(page)
|
|
await expect(page).toHaveURL(/session=/)
|
|
const chatA = new URL(page.url()).searchParams.get("session")
|
|
await page.waitForTimeout(1200)
|
|
await sendMessage(page, "Chat A two")
|
|
const canvas = getIframeContent(page)
|
|
// The half drawn preview stays while the continuation is asked for
|
|
await expect(canvas.getByText("PartOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1500)
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=bee")
|
|
})
|
|
await expect(canvas.getByText("BeeOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1000)
|
|
const saved = await savedDiagram(page, chatA ?? undefined)
|
|
expect(saved).toContain("AyOne")
|
|
expect(saved).not.toContain("PartOne")
|
|
})
|
|
|
|
test("a chat gone back to while another answer streams is not saved again", async ({
|
|
page,
|
|
}) => {
|
|
// Text keeps streaming: the chat's messages update all the time
|
|
await page.addInitScript(() => {
|
|
const realFetch = window.fetch
|
|
window.fetch = async (input, init) => {
|
|
const url = input instanceof Request ? input.url : String(input)
|
|
if (!url.endsWith("/api/chat")) return realFetch(input, init)
|
|
const sse = (e: object) =>
|
|
new TextEncoder().encode(`data: ${JSON.stringify(e)}\n\n`)
|
|
const body = new ReadableStream({
|
|
async start(c) {
|
|
c.enqueue(sse({ type: "start" }))
|
|
c.enqueue(sse({ type: "text-start", id: "t" }))
|
|
for (let i = 0; i < 600; i++) {
|
|
c.enqueue(
|
|
sse({
|
|
type: "text-delta",
|
|
id: "t",
|
|
delta: `w${i} `,
|
|
}),
|
|
)
|
|
await new Promise((r) => setTimeout(r, 40))
|
|
}
|
|
c.close()
|
|
},
|
|
})
|
|
return new Response(body, {
|
|
headers: { "content-type": "text/event-stream" },
|
|
})
|
|
}
|
|
})
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("b", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
await seedSession(page, {
|
|
id: "bee",
|
|
title: "Chat B",
|
|
createdAt: 1000,
|
|
updatedAt: 1000,
|
|
messages: [
|
|
{
|
|
id: "bu",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Chat B" }],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
await sendMessage(page, "Chat A")
|
|
await expect(page.getByText(/w10 /)).toBeVisible({ timeout: 15000 })
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=bee")
|
|
})
|
|
await expect(
|
|
getIframeContent(page).getByText("BeeOne", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
await page.waitForTimeout(3000)
|
|
const updatedAt = await page.evaluate(async () => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
const session: any = await new Promise((resolve) => {
|
|
const request = db
|
|
.transaction("sessions")
|
|
.objectStore("sessions")
|
|
.get("bee")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
db.close()
|
|
return session?.updatedAt
|
|
})
|
|
expect(updatedAt).toBe(1000)
|
|
})
|
|
|
|
test("a chat gone back to while a file is being opened stays locked until the file is open", async ({
|
|
page,
|
|
}) => {
|
|
await page.addInitScript(() => {
|
|
const real = Blob.prototype.text
|
|
Blob.prototype.text = async function () {
|
|
if ((window as any).__slowText) {
|
|
await new Promise((r) => setTimeout(r, 4000))
|
|
}
|
|
return real.call(this)
|
|
}
|
|
})
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("b", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
await seedSession(page, {
|
|
id: "bee",
|
|
title: "Chat B",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "bu",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Chat B" }],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
await page.evaluate(() => {
|
|
;(window as any).__slowText = true
|
|
})
|
|
await openDrawioFile(
|
|
page,
|
|
"slow.drawio",
|
|
`<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("f", "FromFile", 40)}</root></mxGraphModel></diagram></mxfile>`,
|
|
)
|
|
await page.waitForTimeout(300)
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=bee")
|
|
})
|
|
const canvas = getIframeContent(page)
|
|
await expect(canvas.getByText("BeeOne", { exact: true })).toBeVisible({
|
|
timeout: 15000,
|
|
})
|
|
// The file is still being read: nothing can be sent yet
|
|
await expect(getChatInput(page)).toBeDisabled()
|
|
await expect(canvas.getByText("FromFile", { exact: true })).toBeVisible(
|
|
{
|
|
timeout: 15000,
|
|
},
|
|
)
|
|
await expect(getChatInput(page)).toBeEnabled()
|
|
})
|
|
|
|
test("at the chat limit, the oldest chat opened from the list while the new chat waits for its save is kept", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(boxCell("a", "AyOne", 40), "A."),
|
|
}),
|
|
)
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("b", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
// 50 saved chats; the one opened from the list is the oldest
|
|
await page.evaluate(
|
|
async ({ doc }) => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
const store = tx.objectStore("sessions")
|
|
for (let i = 0; i < 50; i++) {
|
|
store.put({
|
|
id: i === 0 ? "oldest" : `chat-${i}`,
|
|
title: i === 0 ? "The oldest" : `Chat ${i}`,
|
|
createdAt: 1000 + i,
|
|
updatedAt: 1000 + i,
|
|
messages: [
|
|
{
|
|
id: `u${i}`,
|
|
role: "user",
|
|
parts: [
|
|
{ type: "text", text: `Chat ${i}` },
|
|
],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
}
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
},
|
|
{ doc },
|
|
)
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await expect(
|
|
page.locator('[data-testid="canvas-loading"]'),
|
|
).toHaveCount(0, { timeout: 30000 })
|
|
const oldest = page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: /The oldest/ })
|
|
await sendMessage(page, "Chat A")
|
|
await waitForComplete(page)
|
|
// Within the auto-save's wait: opening it saves this chat first
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await oldest.click()
|
|
await expect(
|
|
getIframeContent(page).getByText("BeeOne", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
expect((await savedChats(page)).map((c) => c.id)).toContain("oldest")
|
|
})
|
|
|
|
test("typing goes on in the composer after browser back to another chat", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(boxCell("a", "AyOne", 40), "A."),
|
|
}),
|
|
)
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("b", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
await seedSession(page, {
|
|
id: "bee",
|
|
title: "Chat B",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messages: [
|
|
{
|
|
id: "bu",
|
|
role: "user",
|
|
parts: [{ type: "text", text: "Chat B" }],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
await sendMessage(page, "Chat A")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1500)
|
|
const input = getChatInput(page)
|
|
await input.click()
|
|
await page.keyboard.type("Make it ")
|
|
await page.evaluate(() => {
|
|
;(window as any).next.router.replace("?session=bee")
|
|
})
|
|
await expect(
|
|
getIframeContent(page).getByText("BeeOne", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
await page.keyboard.type("blue")
|
|
await expect(input).toHaveValue(/blue/)
|
|
})
|
|
|
|
test("at the chat limit, an auto-save that goes first keeps the oldest chat opened from the list", async ({
|
|
page,
|
|
}) => {
|
|
// Thumbnails wait (at most 3 s): the click's save waits for one, the
|
|
// auto-save of a new chat takes none and goes first
|
|
await page.addInitScript(() => {
|
|
const w = window as any
|
|
const held: MessageEvent[] = []
|
|
w.__holdExports = true
|
|
w.__releaseExports = () => {
|
|
w.__holdExports = false
|
|
for (const e of held.splice(0)) {
|
|
const copy = new MessageEvent("message", {
|
|
data: e.data,
|
|
origin: e.origin,
|
|
source: e.source,
|
|
})
|
|
;(copy as any).__released = true
|
|
window.dispatchEvent(copy)
|
|
}
|
|
}
|
|
window.addEventListener("message", (event) => {
|
|
if (
|
|
w.__holdExports &&
|
|
!(event as any).__released &&
|
|
typeof event.data === "string" &&
|
|
event.data.includes('"event":"export"') &&
|
|
event.data.includes('"message":"thumbnail-')
|
|
) {
|
|
event.stopImmediatePropagation()
|
|
held.push(event)
|
|
}
|
|
})
|
|
})
|
|
await page.route("**/api/chat", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(boxCell("a", "AyOne", 40), "A."),
|
|
}),
|
|
)
|
|
const doc = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${boxCell("b", "BeeOne", 40)}</root></mxGraphModel></diagram></mxfile>`
|
|
await openApp(page)
|
|
await page.evaluate(
|
|
async ({ doc }) => {
|
|
const db: IDBDatabase = await new Promise((resolve) => {
|
|
const request = indexedDB.open("next-ai-drawio")
|
|
request.onsuccess = () => resolve(request.result)
|
|
})
|
|
await new Promise((resolve) => {
|
|
const tx = db.transaction("sessions", "readwrite")
|
|
const store = tx.objectStore("sessions")
|
|
for (let i = 0; i < 50; i++) {
|
|
store.put({
|
|
id: i === 0 ? "oldest" : `chat-${i}`,
|
|
title: i === 0 ? "The oldest" : `Chat ${i}`,
|
|
createdAt: 1000 + i,
|
|
updatedAt: 1000 + i,
|
|
messages: [
|
|
{
|
|
id: `u${i}`,
|
|
role: "user",
|
|
parts: [
|
|
{ type: "text", text: `Chat ${i}` },
|
|
],
|
|
},
|
|
],
|
|
xmlSnapshots: [],
|
|
diagramXml: doc,
|
|
versions: [],
|
|
})
|
|
}
|
|
tx.oncomplete = resolve
|
|
})
|
|
db.close()
|
|
},
|
|
{ doc },
|
|
)
|
|
await page.reload({ waitUntil: "networkidle" })
|
|
await expect(
|
|
page.locator('[data-testid="canvas-loading"]'),
|
|
).toHaveCount(0, { timeout: 30000 })
|
|
await sendMessage(page, "Chat A")
|
|
await waitForComplete(page)
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
await page
|
|
.getByRole("dialog")
|
|
.getByRole("button", { name: /The oldest/ })
|
|
.click()
|
|
// The auto-save runs meanwhile
|
|
await page.waitForTimeout(1500)
|
|
await page.evaluate(() => (window as any).__releaseExports())
|
|
await expect(
|
|
getIframeContent(page).getByText("BeeOne", { exact: true }),
|
|
).toBeVisible({ timeout: 15000 })
|
|
expect((await savedChats(page)).map((c) => c.id)).toContain("oldest")
|
|
})
|
|
})
|