mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-09 19:19:50 +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 - the app's own canvas toolbar, shape library and page tabs replace draw.io's chrome - new start screen, session rename and switcher, open .drawio files - settings split into Models & keys, Appearance, Advanced and About - Bedrock in the model settings also takes an API key instead of access keys - phones switch between canvas and chat - dark mode keeps the diagram on a white sheet and no longer flashes With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the app falls back to draw.io's own toolbar and the features above that need same origin are off. The chat logic moved from the 1500-line chat panel into a chat engine plus small UI components; zustand holds canvas, versions, settings and UI state.
1726 lines
72 KiB
TypeScript
1726 lines
72 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
|
|
}
|
|
|
|
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("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)
|
|
// The percentage shown in the toolbar
|
|
const zoom = () =>
|
|
page.getByRole("button", { name: "Zoom", exact: true }).innerText()
|
|
const initial = await zoom()
|
|
await page.getByRole("button", { name: "Zoom out" }).click()
|
|
await expect.poll(zoom).not.toBe(initial)
|
|
const before = await zoom()
|
|
await page.locator('[data-testid="version-restore"]').first().click()
|
|
await page.waitForTimeout(800)
|
|
expect(await zoom()).toBe(before)
|
|
await expect(page.getByText("Restored v1")).toBeVisible()
|
|
})
|
|
|
|
test("other diagrams can't be opened while an answer streams", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
await page.waitForTimeout(1500)
|
|
await page.locator('[data-testid="new-chat-button"]').click()
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible()
|
|
// An answer that takes a while
|
|
await page.unroute("**/api/chat")
|
|
await page.route("**/api/chat", async (route) => {
|
|
await new Promise((r) => setTimeout(r, 4000))
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: createMockSSEResponse(FLOWCHART_XML, "Second one."),
|
|
})
|
|
})
|
|
await sendMessage(page, "Create another flowchart")
|
|
await page.locator('[data-testid="session-title"]').click()
|
|
const other = page.getByRole("button", { name: /Create a flowchart/ })
|
|
await expect(other).toHaveAttribute("aria-disabled", "true")
|
|
await other.click({ force: true })
|
|
await waitForComplete(page)
|
|
await expect(page.getByText("Second one.")).toBeVisible()
|
|
})
|
|
|
|
test("the Shapes button and its shortcut open the shape library", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
const library = getIframeContent(page).locator(
|
|
".geSidebarContainer:not(.geFormatContainer)",
|
|
)
|
|
await expect(library).toBeHidden()
|
|
await page.locator('[data-testid="toolbar-shapes"]').click()
|
|
await expect(library).toBeVisible()
|
|
await page.locator('[data-testid="toolbar-shapes"]').click()
|
|
await expect(library).toBeHidden()
|
|
await page.keyboard.press("ControlOrMeta+Shift+K")
|
|
await expect(library).toBeVisible()
|
|
// Also with the focus in the canvas
|
|
await getIframeContent(page)
|
|
.locator(".geDiagramContainer")
|
|
.click({ position: { x: 300, y: 300 } })
|
|
await page.keyboard.press("ControlOrMeta+Shift+K")
|
|
await expect(library).toBeHidden()
|
|
})
|
|
|
|
test("resizing the window leaves draw.io's style panel closed", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
const formatWidth = () =>
|
|
getIframeContent(page)
|
|
.locator(".geFormatContainer")
|
|
.evaluate((el) => (el as HTMLElement).offsetWidth)
|
|
expect(await formatWidth()).toBe(0)
|
|
// draw.io's own rule opens it when its window gets wide again
|
|
await page.setViewportSize({ width: 1000, height: 800 })
|
|
await page.waitForTimeout(600)
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.waitForTimeout(1500)
|
|
expect(await formatWidth()).toBe(0)
|
|
})
|
|
|
|
test("crossing the phone width keeps draw.io and its undo history", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/api/chat", drawThenEdit())
|
|
await openApp(page)
|
|
await sendMessage(page, "Create a flowchart")
|
|
await waitForComplete(page)
|
|
// A mark on draw.io's window: a reload would make a new window
|
|
const mark = (set: boolean) =>
|
|
getIframe(page).evaluate((el, set) => {
|
|
const w = (el as HTMLIFrameElement).contentWindow as any
|
|
if (set) w.__naiMark = 1
|
|
return w.__naiMark
|
|
}, set)
|
|
await mark(true)
|
|
await page.setViewportSize({ width: 700, height: 800 })
|
|
await page.waitForTimeout(800)
|
|
await page.setViewportSize({ width: 1280, height: 800 })
|
|
await page.waitForTimeout(800)
|
|
expect(await mark(false)).toBe(1)
|
|
await expect(page.locator('[data-testid="toolbar-undo"]')).toBeEnabled()
|
|
})
|
|
|
|
test("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 does not show as running", 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 })
|
|
await expect(page.getByText("Stopped", { exact: true })).toBeVisible()
|
|
await expect(page.getByText("Drawing the diagram…")).toHaveCount(0)
|
|
})
|
|
|
|
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()
|
|
const a = await boxOf(models)
|
|
const b = await boxOf(appearance)
|
|
expect(Math.abs(a.y - b.y)).toBeLessThan(2)
|
|
})
|
|
|
|
test("New chat brings the start screen back", async ({ page }) => {
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toHaveCount(0)
|
|
await page.locator('[data-testid="new-chat-button"]').click()
|
|
await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible()
|
|
})
|
|
|
|
test("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 the 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 = page.getByRole("toolbar")
|
|
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)
|
|
// The short toolbar fits the canvas, export included
|
|
const bar = await boxOf(toolbar)
|
|
expect(bar.x).toBeGreaterThanOrEqual(stage.x)
|
|
expect(bar.x + bar.width).toBeLessThanOrEqual(stage.x + stage.width)
|
|
await expect(
|
|
page.locator('[data-testid="toolbar-export"]'),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("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 paper size changed by hand after an AI change takes its Undo away", 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 expect(page.locator('[data-testid="version-undo"]')).toHaveText(
|
|
"Undo change",
|
|
)
|
|
// A3 in draw.io's own format panel, nothing else changed
|
|
await page.getByRole("button", { name: "Style" }).click()
|
|
const paper = getIframeContent(page)
|
|
.locator(".geFormatContainer select")
|
|
.first()
|
|
await expect(paper).toBeAttached()
|
|
await paper.evaluate((select: HTMLSelectElement) => {
|
|
select.value = "a3"
|
|
select.dispatchEvent(new Event("change", { bubbles: true }))
|
|
})
|
|
await expect(page.locator('[data-testid="version-undo"]')).toHaveCount(
|
|
0,
|
|
)
|
|
await expect(
|
|
page.locator('[data-testid="version-restore"]'),
|
|
).toBeEnabled()
|
|
})
|
|
|
|
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("keys reach the canvas after the app's toolbar was used", 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()
|
|
await page.getByRole("button", { name: "Zoom in" }).click()
|
|
await page.keyboard.press("Delete")
|
|
await expect(canvas.getByText("Process", { exact: true })).toHaveCount(
|
|
0,
|
|
)
|
|
})
|
|
|
|
test("page menu: rename takes typing, duplicate names the copy", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
const tabs = page.locator('[data-testid="page-tabs"]')
|
|
await tabs.getByRole("button", { name: "Page-1" }).click()
|
|
await page.getByRole("button", { name: "Duplicate" }).click()
|
|
await expect(tabs).toContainText("Copy of Page-1")
|
|
// The copy is the current page now: rename it
|
|
await tabs.getByRole("button", { name: "Copy of Page-1" }).click()
|
|
await page.getByRole("button", { name: "Rename" }).click()
|
|
await page.waitForTimeout(300)
|
|
await page.keyboard.press("ControlOrMeta+a")
|
|
await page.keyboard.type("Second")
|
|
await page.keyboard.press("Enter")
|
|
await expect(tabs).toContainText("Second")
|
|
})
|
|
|
|
test("the layers window lists the layers", async ({ page }) => {
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
await page.locator('[data-testid="toolbar-more"]').click()
|
|
await page.getByRole("button", { name: "Layers" }).click()
|
|
await expect(
|
|
getIframeContent(page)
|
|
.locator(".mxWindowPane .geToolbarContainer")
|
|
.first(),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test("the toolbar follows freehand drawing started in the canvas", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
const freehand = page.getByRole("button", { name: "Freehand" })
|
|
await getIframeContent(page)
|
|
.locator(".geDiagramContainer")
|
|
.click({ position: { x: 300, y: 300 } })
|
|
await page.keyboard.press("x")
|
|
await expect(freehand).toHaveAttribute("aria-pressed", "true")
|
|
// The Select button stops it, and draw.io's freehand window goes
|
|
await page.getByRole("button", { name: "Select" }).click()
|
|
await expect(freehand).not.toHaveAttribute("aria-pressed", "true")
|
|
await expect(
|
|
getIframeContent(page).locator(".mxWindow:visible"),
|
|
).toHaveCount(0)
|
|
})
|
|
|
|
test("the zoom menu shows draw.io's own shortcuts", async ({ page }) => {
|
|
await openApp(page)
|
|
await page.locator('[data-testid="draw-yourself"]').click()
|
|
await page.getByRole("button", { name: "Zoom", exact: true }).click()
|
|
await expect(page.getByRole("button", { name: /Home/ })).toBeVisible()
|
|
})
|
|
|
|
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 = page.locator('[data-testid="page-tabs"]')
|
|
await page.getByRole("button", { name: "Add page" }).click()
|
|
await tabs.getByRole("button", { name: "Main" }).click()
|
|
await tabs.getByRole("button", { name: "Main" }).click()
|
|
await page.getByRole("button", { name: "Delete page" }).click()
|
|
await expect(tabs.getByRole("button", { name: "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(page.locator('[data-testid="page-tabs"]')).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 = page.locator('[data-testid="page-tabs"]')
|
|
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(page.locator('[data-testid="page-tabs"]')).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 page.getByRole("button", { name: "Zoom out" }).click()
|
|
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="toolbar-export"]').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")
|
|
})
|
|
})
|