mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 19:49:52 +08:00
feat(ui): canvas-first redesign with a self-hosted draw.io
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 - 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.
This commit is contained in:
@@ -32,9 +32,10 @@ test("a rejected key shows a hint, the provider's words and a settings button",
|
||||
// First match: the Next.js dev overlay at the end also lists the error
|
||||
await expect(page.getByText("Authentication Fails").first()).toBeVisible()
|
||||
await page.getByRole("button", { name: "Open model settings" }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "AI Model Configuration" }),
|
||||
).toBeVisible()
|
||||
// Settings opens on its models tab
|
||||
const dialog = page.getByRole("dialog", { name: "Settings" })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("an error before the stream shows its text, not raw JSON", async ({
|
||||
@@ -98,7 +99,8 @@ test("a refused server key shows only the quota hint and a settings button", asy
|
||||
0,
|
||||
)
|
||||
await page.getByRole("button", { name: "Open model settings" }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "AI Model Configuration" }),
|
||||
).toBeVisible()
|
||||
// Settings opens on its models tab
|
||||
const dialog = page.getByRole("dialog", { name: "Settings" })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
+8
-11
@@ -71,38 +71,35 @@ test.describe("Crossing the mobile breakpoint", () => {
|
||||
})
|
||||
|
||||
test("keeps the chat and its streaming answer", async ({ page }) => {
|
||||
const chat = page.locator('[data-panel-id="chat-panel"]')
|
||||
await sendMessage(page, "Tell me a story")
|
||||
await expect(page.getByText("Once upon")).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Phones show the canvas or the chat, one at a time
|
||||
await page.setViewportSize({ width: 600, height: 900 })
|
||||
await page.getByRole("button", { name: "Chat", exact: true }).click()
|
||||
await expect(page.getByText("Tell me a story")).toBeVisible()
|
||||
// Half the height on mobile
|
||||
await expect
|
||||
.poll(async () => (await chat.boundingBox())?.height ?? 0)
|
||||
.toBeCloseTo(450, -1)
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
// A third of the width on desktop
|
||||
await expect
|
||||
.poll(async () => (await chat.boundingBox())?.width ?? 0)
|
||||
.toBeCloseTo(1280 / 3, -1)
|
||||
await expect(page.getByText("Once upon a time.")).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(page.getByText("Tell me a story")).toBeVisible()
|
||||
})
|
||||
|
||||
test("opens a chat collapsed on desktop", async ({ page }) => {
|
||||
await page.locator("button:has(svg.lucide-panel-right-close)").click()
|
||||
test("reaches a chat hidden on desktop", async ({ page }) => {
|
||||
// The start screen has no chat panel yet
|
||||
await page.getByTestId("draw-yourself").click()
|
||||
await page.getByRole("button", { name: "Hide chat panel" }).click()
|
||||
await expect(getChatInput(page)).toBeHidden()
|
||||
|
||||
await page.setViewportSize({ width: 600, height: 900 })
|
||||
await page.getByRole("button", { name: "Chat", exact: true }).click()
|
||||
await expect(getChatInput(page)).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await page.getByTestId("show-panel").click()
|
||||
await expect(getChatInput(page)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import zlib from "node:zlib"
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
import { getIframe, sendMessage, waitForCompleteCount } from "./lib/fixtures"
|
||||
import {
|
||||
getIframe,
|
||||
openDrawioFile,
|
||||
sendMessage,
|
||||
waitForCompleteCount,
|
||||
} from "./lib/fixtures"
|
||||
|
||||
/**
|
||||
* Checks what draw.io actually shows after the diagram tools, not only the
|
||||
@@ -45,6 +51,15 @@ const cell = (id: string, label: string, x: number) =>
|
||||
const page = (id: string, cells: string) =>
|
||||
`<diagram id="${id}" name="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
|
||||
|
||||
// A screenshot check that finds a problem
|
||||
const FAILED_CHECK = {
|
||||
valid: false,
|
||||
issues: [
|
||||
{ type: "overlap", severity: "critical", description: "Boxes overlap" },
|
||||
],
|
||||
suggestions: ["Move them apart"],
|
||||
}
|
||||
|
||||
const sse = (events: object[]) =>
|
||||
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
|
||||
const EDIT_GAMMA = {
|
||||
@@ -127,7 +142,9 @@ test("display_diagram replaces the document with the fixed diagram", async ({
|
||||
await sendMessage(p, "Draw two pages")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await expect(canvas.getByText("Old A")).toBeVisible({ timeout: 15000 })
|
||||
await expect(canvas.getByText("Second", { exact: true })).toBeVisible()
|
||||
// The app's page switcher (draw.io's own page tabs are hidden)
|
||||
const pageTabs = p.getByTestId("page-tabs")
|
||||
await expect(pageTabs.getByText("Second", { exact: true })).toBeVisible()
|
||||
|
||||
await sendMessage(p, "Start over with three boxes")
|
||||
await waitForCompleteCount(p, 2)
|
||||
@@ -140,7 +157,7 @@ test("display_diagram replaces the document with the fixed diagram", async ({
|
||||
}
|
||||
// The old pages are gone
|
||||
await expect(canvas.getByText("Old A")).toHaveCount(0)
|
||||
await expect(canvas.getByText("Second", { exact: true })).toHaveCount(0)
|
||||
await expect(pageTabs.getByText("Second", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("an edit with a fixable cell is fixed, not rejected", async ({
|
||||
@@ -221,9 +238,14 @@ test("edit_diagram applies all operations or none", async ({ page: p }) => {
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toHaveCount(0)
|
||||
|
||||
await sendMessage(p, "Change again")
|
||||
await expect(p.getByText(/No changes were made/).first()).toBeAttached({
|
||||
timeout: 15000,
|
||||
})
|
||||
// The failed call's row opens to show the error
|
||||
await p
|
||||
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
|
||||
.first()
|
||||
.getByRole("button")
|
||||
.first()
|
||||
.click({ timeout: 15000 })
|
||||
await expect(p.getByText(/No changes were made/).first()).toBeAttached()
|
||||
await p.waitForTimeout(1000)
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible()
|
||||
await expect(canvas.getByText("Broken", { exact: true })).toHaveCount(0)
|
||||
@@ -609,9 +631,9 @@ test("a broken edit's preview is undone after a shape library call", async ({
|
||||
await waitForCompleteCount(p, 1)
|
||||
await sendMessage(p, "Add another box")
|
||||
// The preview shows Gamma only for a moment; afterwards it must be gone
|
||||
await expect(p.getByText("Get Shape Library").first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(
|
||||
p.locator('[data-tool-name="get_shape_library"]').first(),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await p.waitForTimeout(2000)
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
@@ -640,10 +662,12 @@ test("a drawing rejected by the checks undoes its preview", async ({
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
// The tool card shows the rejection
|
||||
await expect(p.locator('text="Error"').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
// The tool row shows the rejection
|
||||
await expect(
|
||||
p
|
||||
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await p.waitForTimeout(1000)
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
@@ -735,3 +759,345 @@ test("stopping during the screenshot check lets the next message go at once", as
|
||||
.find((part: any) => part.type === "tool-display_diagram")
|
||||
expect(draw?.state).toBe("output-available")
|
||||
})
|
||||
|
||||
test("the request after a failed screenshot check keeps the model and diagram", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
// After a failed check the SDK sends the next request itself; it must
|
||||
// carry the turn's model headers and diagram
|
||||
await p.addInitScript(() => {
|
||||
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
|
||||
})
|
||||
const requests: { body: any; headers: Record<string, string> }[] = []
|
||||
await p.route("**/api/chat", async (route) => {
|
||||
requests.push({
|
||||
body: route.request().postDataJSON(),
|
||||
headers: route.request().headers(),
|
||||
})
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: drawReply(`d${requests.length}`, cell("a", "Alpha", 40)),
|
||||
})
|
||||
})
|
||||
let checks = 0
|
||||
await p.route("**/api/validate-diagram", async (route) => {
|
||||
checks++
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/plain",
|
||||
body: JSON.stringify(
|
||||
checks === 1
|
||||
? FAILED_CHECK
|
||||
: { valid: true, issues: [], suggestions: [] },
|
||||
),
|
||||
})
|
||||
})
|
||||
await p.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
|
||||
await sendMessage(p, "Draw a box")
|
||||
await expect.poll(() => requests.length, { timeout: 20000 }).toBe(2)
|
||||
const [first, retry] = requests
|
||||
expect(retry.body.sessionId).toBe(first.body.sessionId)
|
||||
expect(typeof retry.body.xml).toBe("string")
|
||||
for (const name of Object.keys(first.headers)) {
|
||||
if (name.startsWith("x-")) {
|
||||
expect(retry.headers[name], name).toBe(first.headers[name])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// A display_diagram cut off by the output limit: its last cell is half
|
||||
// written, so the model has to continue with append_diagram
|
||||
const CUT = toolCallEvents("d2", "display_diagram", {
|
||||
xml: `${cell("b", "Beta", 40)}<mxCell id="c" value="Gam`,
|
||||
})
|
||||
const cutReply = [
|
||||
sse([{ type: "start" }, CUT.start, ...CUT.deltas]),
|
||||
1500,
|
||||
`${sse([CUT.done, { type: "finish" }])}data: [DONE]\n\n`,
|
||||
]
|
||||
|
||||
test("a cut off drawing stays while it is continued, and goes if that fails", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[drawReply("d1", cell("a", "Alpha", 40))],
|
||||
cutReply,
|
||||
[
|
||||
sse([{ type: "start" }]),
|
||||
...KEEP_OPEN.slice(0, 10),
|
||||
`${sse([{ type: "error", errorText: "Upstream connection lost" }])}data: [DONE]\n\n`,
|
||||
],
|
||||
])
|
||||
await sendMessage(p, "Draw a box")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await sendMessage(p, "Draw a bigger one")
|
||||
// The cut off call has its result; the continuation is running
|
||||
await expect(
|
||||
p.locator(
|
||||
'[data-tool-name="display_diagram"][data-tool-state="output-error"]',
|
||||
),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await p.waitForTimeout(800)
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible()
|
||||
await expect(p.getByText("Upstream connection lost").first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("a finished continuation is one change, undone back to the diagram before", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const rest = toolCallEvents("a1", "append_diagram", {
|
||||
xml: `ma" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="220" y="40" width="120" height="60" as="geometry"/></mxCell>`,
|
||||
})
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[drawReply("d1", cell("a", "Alpha", 40))],
|
||||
cutReply,
|
||||
[
|
||||
`${sse([{ type: "start" }, rest.start, ...rest.deltas, rest.done, { type: "finish" }])}data: [DONE]\n\n`,
|
||||
],
|
||||
])
|
||||
await sendMessage(p, "Draw a box")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await sendMessage(p, "Draw a bigger one")
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible()
|
||||
await expect(p.locator('[data-testid="new-chat-button"]')).toBeEnabled({
|
||||
timeout: 15000,
|
||||
})
|
||||
await p.waitForTimeout(1500)
|
||||
await p.locator('[data-testid="version-undo"]').click()
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a cut off drawing goes when the model stops without continuing", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[drawReply("d1", cell("a", "Alpha", 40))],
|
||||
cutReply,
|
||||
[textReply("I could not finish it.")],
|
||||
])
|
||||
await sendMessage(p, "Draw a box")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await sendMessage(p, "Draw a bigger one")
|
||||
await expect(p.getByText("I could not finish it.")).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
// The first drawing is on the canvas again: its card can still undo it
|
||||
await p.waitForTimeout(1500)
|
||||
await expect(p.locator('[data-testid="version-undo"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("stopping a continuation leaves the diagram from before the cut off one", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
// The model draws anew instead of continuing, and the user stops it
|
||||
const redraw = toolCallEvents("d3", "display_diagram", {
|
||||
xml: cell("g", "Gamma", 400),
|
||||
})
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[drawReply("d1", cell("a", "Alpha", 40))],
|
||||
cutReply,
|
||||
[
|
||||
sse([{ type: "start" }, redraw.start, ...redraw.deltas]),
|
||||
...KEEP_OPEN,
|
||||
],
|
||||
])
|
||||
await sendMessage(p, "Draw a box")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await sendMessage(p, "Draw a bigger one")
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await p.getByRole("button", { name: "Stop generation" }).click()
|
||||
await p.waitForTimeout(1000)
|
||||
await expect(canvas.getByText("Gamma", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("a drawing that failed its screenshot check can still be restored", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
await p.addInitScript(() => {
|
||||
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
|
||||
})
|
||||
let chatRequests = 0
|
||||
await p.route("**/api/chat", async (route) => {
|
||||
chatRequests++
|
||||
if (chatRequests === 1) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: drawReply("d1", cell("a", "Alpha", 40)),
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Model is overloaded" }),
|
||||
})
|
||||
})
|
||||
await p.route("**/api/validate-diagram", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/plain",
|
||||
body: JSON.stringify(FAILED_CHECK),
|
||||
}),
|
||||
)
|
||||
await p.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
|
||||
await sendMessage(p, "Draw a box")
|
||||
await expect(p.getByText("Model is overloaded").first()).toBeVisible({
|
||||
timeout: 20000,
|
||||
})
|
||||
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("retrying after an error keeps what the failed turn drew", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const draw = toolCallEvents("d1", "display_diagram", {
|
||||
xml: cell("a", "Alpha", 40),
|
||||
})
|
||||
const canvas = await chunkedReplies(p, [
|
||||
[
|
||||
sse([{ type: "start" }, draw.start, ...draw.deltas, draw.done]),
|
||||
`${sse([{ type: "error", errorText: "Model is overloaded" }])}data: [DONE]\n\n`,
|
||||
],
|
||||
[textReply("Nothing to draw this time.")],
|
||||
])
|
||||
await sendMessage(p, "Draw a box")
|
||||
await expect(p.getByText("Model is overloaded").first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await p.locator('[data-testid="retry-button"]').click()
|
||||
await expect(p.getByText("Nothing to draw this time.")).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
// Its card went with the answer; the strip keeps the version
|
||||
const thumb = p.locator('[data-testid="version-thumb"]')
|
||||
await expect(thumb).toHaveCount(1)
|
||||
await thumb.click()
|
||||
await p.locator('[data-testid="compare-restore"]').click()
|
||||
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("the request after opening a compressed file has the cells", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const requests: any[] = []
|
||||
await p.route("**/api/chat", async (route) => {
|
||||
requests.push(route.request().postDataJSON())
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: textReply("Looks fine."),
|
||||
})
|
||||
})
|
||||
await p.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
|
||||
// draw.io's compressed page: raw deflate of the URI-encoded model
|
||||
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cell("a", "Packed", 40)}</root></mxGraphModel>`
|
||||
const packed = zlib
|
||||
.deflateRawSync(Buffer.from(encodeURIComponent(model)))
|
||||
.toString("base64")
|
||||
await openDrawioFile(
|
||||
p,
|
||||
"packed.drawio",
|
||||
`<mxfile><diagram name="P" id="p">${packed}</diagram></mxfile>`,
|
||||
)
|
||||
await expect(
|
||||
p.frameLocator("iframe").getByText("Packed", { exact: true }),
|
||||
).toBeVisible()
|
||||
await sendMessage(p, "What is this?")
|
||||
await expect(p.getByText("Looks fine.")).toBeVisible()
|
||||
// Regenerate sends the snapshot taken before the message
|
||||
await p.getByText("Looks fine.").hover()
|
||||
await p.getByRole("button", { name: "Regenerate response" }).click()
|
||||
await expect.poll(() => requests.length).toBe(2)
|
||||
expect(requests[1].xml).toContain('value="Packed"')
|
||||
})
|
||||
|
||||
test("a rejected drawing stops saying it tries again once retries end", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
// Rejected by the checks every time: the automatic retries run out
|
||||
let calls = 0
|
||||
await p.route("**/api/chat", async (route) => {
|
||||
calls++
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: drawReply(
|
||||
`r${calls}`,
|
||||
`<UserObject id="1" label="Bad" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell></UserObject>`,
|
||||
),
|
||||
})
|
||||
})
|
||||
await p.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
|
||||
await sendMessage(p, "Draw a box")
|
||||
await expect.poll(() => calls, { timeout: 20000 }).toBe(4)
|
||||
await expect(p.locator('[data-testid="new-chat-button"]')).toBeEnabled({
|
||||
timeout: 15000,
|
||||
})
|
||||
await p.waitForTimeout(500)
|
||||
expect(calls).toBe(4)
|
||||
await expect(p.getByText("trying again")).toHaveCount(0)
|
||||
await expect(p.getByText("This attempt failed").first()).toBeVisible()
|
||||
})
|
||||
|
||||
test("the version strip stays away while the first drawing is checked", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
await p.addInitScript(() => {
|
||||
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
|
||||
})
|
||||
await p.route("**/api/chat", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: drawReply("d1", cell("a", "Alpha", 40)),
|
||||
}),
|
||||
)
|
||||
let checking = false
|
||||
await p.route("**/api/validate-diagram", async (route) => {
|
||||
checking = true
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
await route
|
||||
.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/plain",
|
||||
body: JSON.stringify({
|
||||
valid: true,
|
||||
issues: [],
|
||||
suggestions: [],
|
||||
}),
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
await p.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
|
||||
await sendMessage(p, "Draw a box")
|
||||
await expect.poll(() => checking, { timeout: 15000 }).toBe(true)
|
||||
// The version exists already; its card comes when the check is done
|
||||
await p.waitForTimeout(1000)
|
||||
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(0)
|
||||
await expect(p.locator('[data-testid="version-card"]')).toHaveCount(1, {
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -35,10 +35,8 @@ test.describe("Diagram Generation", () => {
|
||||
|
||||
test("generates and displays a diagram", async ({ page }) => {
|
||||
await sendMessage(page, "Draw a cat")
|
||||
await expect(page.locator('text="Generate Diagram"')).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await waitForComplete(page)
|
||||
await expect(page.locator('[data-testid="version-card"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("chat input clears after sending", async ({ page }) => {
|
||||
|
||||
@@ -169,9 +169,14 @@ test.describe("Error Handling", () => {
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await sendMessage(page, "Draw something")
|
||||
|
||||
// The failed call's row opens to show the error
|
||||
const failed = page
|
||||
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
|
||||
.first()
|
||||
await failed.getByRole("button").first().click({ timeout: 15000 })
|
||||
await expect(
|
||||
page.getByText("Invalid input for tool display_diagram").first(),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
).toBeVisible()
|
||||
await expect(page.locator('text="Truncated"')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
|
||||
import {
|
||||
expect,
|
||||
getAttachmentInput,
|
||||
getChatInput,
|
||||
getIframe,
|
||||
sendMessage,
|
||||
@@ -13,19 +14,22 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
const uploadButton = page.locator(
|
||||
'button[aria-label="Upload file"], button:has(svg.lucide-image)',
|
||||
)
|
||||
await expect(uploadButton.first()).toBeVisible({ timeout: 10000 })
|
||||
await expect(uploadButton.first()).toBeEnabled()
|
||||
// The "Add" menu holds the upload entry
|
||||
const addButton = page.locator('[data-testid="composer-add"]')
|
||||
await expect(addButton).toBeVisible({ timeout: 10000 })
|
||||
await addButton.click()
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
page.getByRole("button", { name: "Upload file" }).click(),
|
||||
])
|
||||
expect(chooser.isMultiple()).toBe(true)
|
||||
})
|
||||
|
||||
test("shows file preview after selecting image", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const fileInput = getAttachmentInput(page)
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "test-image.png",
|
||||
@@ -45,8 +49,7 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const fileInput = getAttachmentInput(page)
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "test-image.png",
|
||||
@@ -93,8 +96,7 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const fileInput = getAttachmentInput(page)
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "architecture.png",
|
||||
@@ -118,8 +120,7 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const fileInput = getAttachmentInput(page)
|
||||
const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x")
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
|
||||
@@ -80,7 +80,7 @@ test.describe("History and Session Restore", () => {
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(1000)
|
||||
// Still the conversation and its diagram, not the empty chat's examples
|
||||
await expect(page.getByText("Paper to Diagram")).toHaveCount(0)
|
||||
await expect(page.getByText("Paper to diagram")).toHaveCount(0)
|
||||
await expect(
|
||||
getIframeContent(page).getByText("Test Box", { exact: true }),
|
||||
).toBeVisible()
|
||||
@@ -117,7 +117,50 @@ test.describe("History and Session Restore", () => {
|
||||
await expect(
|
||||
page.locator('text="Created your test diagram."'),
|
||||
).toHaveCount(0, { timeout: 5000 })
|
||||
await expect(page.getByText("Paper to Diagram")).toBeVisible()
|
||||
await expect(page.getByText("Paper to diagram")).toBeVisible()
|
||||
})
|
||||
|
||||
test("the offer to go on without saving goes away with its chat", async ({
|
||||
page,
|
||||
}) => {
|
||||
let n = 0
|
||||
await page.route("**/api/chat", async (route) => {
|
||||
n++
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: createMockSSEResponse(SINGLE_BOX_XML, `Answer ${n}.`),
|
||||
})
|
||||
})
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await sendMessage(page, "First chat")
|
||||
await waitForText(page, "Answer 1.")
|
||||
await page.waitForTimeout(1500)
|
||||
await page.locator('[data-testid="new-chat-button"]').click()
|
||||
await sendMessage(page, "Second chat")
|
||||
await waitForText(page, "Answer 2.")
|
||||
// Storage is full: leaving the second chat offers to go on unsaved
|
||||
await page.evaluate(() => {
|
||||
const put = IDBObjectStore.prototype.put
|
||||
;(window as any).__restorePut = () => {
|
||||
IDBObjectStore.prototype.put = put
|
||||
}
|
||||
IDBObjectStore.prototype.put = () => {
|
||||
throw new DOMException("Storage is full", "QuotaExceededError")
|
||||
}
|
||||
})
|
||||
await page.locator('[data-testid="new-chat-button"]').click()
|
||||
const offer = page.getByRole("button", {
|
||||
name: "Continue without saving",
|
||||
})
|
||||
await expect(offer).toBeVisible({ timeout: 5000 })
|
||||
// Storage works again, and the user opens the first chat
|
||||
await page.evaluate(() => (window as any).__restorePut())
|
||||
await page.locator('[data-testid="session-title"]').click()
|
||||
await page.getByRole("button", { name: /First chat/ }).click()
|
||||
await expect(page.getByText("Answer 1.")).toBeVisible()
|
||||
await expect(offer).toHaveCount(0)
|
||||
})
|
||||
|
||||
// A diagram drawn by hand, without chat messages: loaded into draw.io
|
||||
@@ -155,6 +198,8 @@ test.describe("History and Session Restore", () => {
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
// Leave the start screen to draw on the canvas
|
||||
await page.getByTestId("draw-yourself").click()
|
||||
await drawByHand(page, "Hand drawn")
|
||||
await expect(
|
||||
getIframeContent(page).getByText("Hand drawn", { exact: true }),
|
||||
@@ -188,17 +233,21 @@ test.describe("History and Session Restore", () => {
|
||||
await waitForText(page, "Created your test diagram.")
|
||||
await page.waitForTimeout(1500)
|
||||
await page.locator('[data-testid="new-chat-button"]').click()
|
||||
// The empty chat lists the first one; draw something by hand
|
||||
const firstChat = page.getByRole("button", {
|
||||
name: /Create a test diagram/,
|
||||
// The empty chat shows the start screen; draw something by hand
|
||||
await expect(page.getByTestId("lobby-hero")).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(firstChat).toBeVisible({ timeout: 10000 })
|
||||
await page.getByTestId("draw-yourself").click()
|
||||
await drawByHand(page, "Hand drawn")
|
||||
await expect(
|
||||
getIframeContent(page).getByText("Hand drawn", { exact: true }),
|
||||
).toBeVisible({ timeout: 10000 })
|
||||
await storageFull(page)
|
||||
await firstChat.click()
|
||||
// The chat list lists the first one
|
||||
await page.getByTestId("session-title").click()
|
||||
await page
|
||||
.getByRole("button", { name: /Create a test diagram/ })
|
||||
.click()
|
||||
await expect(
|
||||
page.getByText(/Could not save this chat/).first(),
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
@@ -284,11 +333,9 @@ test.describe("History and Session Restore", () => {
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
const frame = getIframeContent(page)
|
||||
await expect(
|
||||
frame
|
||||
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30000 })
|
||||
await expect(frame.locator(".geDiagramContainer").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
})
|
||||
|
||||
test("can restore from browser back/forward", async ({ page }) => {
|
||||
|
||||
@@ -18,11 +18,9 @@ test.describe("Iframe Interaction", () => {
|
||||
|
||||
// iframe should have loaded draw.io content
|
||||
const frame = getIframeContent(page)
|
||||
await expect(
|
||||
frame
|
||||
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30000 })
|
||||
await expect(frame.locator(".geDiagramContainer").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
})
|
||||
|
||||
test("can interact with draw.io toolbar", async ({ page }) => {
|
||||
|
||||
+21
-21
@@ -1,12 +1,22 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import {
|
||||
expect,
|
||||
getChatInput,
|
||||
getIframe,
|
||||
openSettings,
|
||||
getSendButton,
|
||||
openSettingsTab,
|
||||
sleep,
|
||||
test,
|
||||
} from "./lib/fixtures"
|
||||
|
||||
/** Pick a language in the settings, then close them */
|
||||
async function pickLanguage(page: Page, name: string) {
|
||||
await openSettingsTab(page, "appearance")
|
||||
await page.locator("#language-select").click()
|
||||
await page.getByRole("option", { name, exact: true }).click()
|
||||
await page.keyboard.press("Escape")
|
||||
}
|
||||
|
||||
test.describe("Language Switching", () => {
|
||||
test("loads English by default", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
@@ -15,7 +25,7 @@ test.describe("Language Switching", () => {
|
||||
const chatInput = getChatInput(page)
|
||||
await expect(chatInput).toBeVisible({ timeout: 10000 })
|
||||
|
||||
await expect(page.locator('button:has-text("Send")')).toBeVisible()
|
||||
await expect(getSendButton(page, "Send")).toBeVisible()
|
||||
})
|
||||
|
||||
test("can switch to Japanese", async ({ page }) => {
|
||||
@@ -23,14 +33,11 @@ test.describe("Language Switching", () => {
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
await test.step("open settings and select Japanese", async () => {
|
||||
await openSettings(page)
|
||||
const languageSelector = page.locator('button:has-text("English")')
|
||||
await languageSelector.first().click()
|
||||
await page.locator('text="日本語"').click()
|
||||
await pickLanguage(page, "日本語")
|
||||
})
|
||||
|
||||
await test.step("verify UI is in Japanese", async () => {
|
||||
await expect(page.locator('button:has-text("送信")')).toBeVisible({
|
||||
await expect(getSendButton(page, "送信")).toBeVisible({
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
@@ -41,14 +48,11 @@ test.describe("Language Switching", () => {
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
await test.step("open settings and select Chinese", async () => {
|
||||
await openSettings(page)
|
||||
const languageSelector = page.locator('button:has-text("English")')
|
||||
await languageSelector.first().click()
|
||||
await page.locator('text="中文"').click()
|
||||
await pickLanguage(page, "中文")
|
||||
})
|
||||
|
||||
await test.step("verify UI is in Chinese", async () => {
|
||||
await expect(page.locator('button:has-text("发送")')).toBeVisible({
|
||||
await expect(getSendButton(page, "发送")).toBeVisible({
|
||||
timeout: 5000,
|
||||
})
|
||||
})
|
||||
@@ -59,16 +63,12 @@ test.describe("Language Switching", () => {
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
await test.step("switch to Japanese", async () => {
|
||||
await openSettings(page)
|
||||
const languageSelector = page.locator('button:has-text("English")')
|
||||
await languageSelector.first().click()
|
||||
await page.locator('text="日本語"').click()
|
||||
await page.keyboard.press("Escape")
|
||||
await pickLanguage(page, "日本語")
|
||||
await sleep(500)
|
||||
})
|
||||
|
||||
await test.step("verify Japanese before reload", async () => {
|
||||
await expect(page.locator('button:has-text("送信")')).toBeVisible({
|
||||
await expect(getSendButton(page, "送信")).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
})
|
||||
@@ -78,7 +78,7 @@ test.describe("Language Switching", () => {
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
// Wait for hydration and localStorage to be read
|
||||
await sleep(1000)
|
||||
await expect(page.locator('button:has-text("送信")')).toBeVisible({
|
||||
await expect(getSendButton(page, "送信")).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
})
|
||||
@@ -88,7 +88,7 @@ test.describe("Language Switching", () => {
|
||||
await page.goto("/ja", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
await expect(page.locator('button:has-text("送信")')).toBeVisible({
|
||||
await expect(getSendButton(page, "送信")).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
})
|
||||
@@ -97,7 +97,7 @@ test.describe("Language Switching", () => {
|
||||
await page.goto("/zh", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
await expect(page.locator('button:has-text("发送")')).toBeVisible({
|
||||
await expect(getSendButton(page, "发送")).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,7 @@ export { expect }
|
||||
|
||||
/** Get the chat input textarea */
|
||||
export function getChatInput(page: Page) {
|
||||
return page.locator('textarea[aria-label="Chat input"]')
|
||||
return page.getByTestId("chat-input")
|
||||
}
|
||||
|
||||
/** Get the draw.io iframe */
|
||||
@@ -43,6 +43,18 @@ export function getIframeContent(page: Page) {
|
||||
return page.frameLocator("iframe")
|
||||
}
|
||||
|
||||
/** Open a .drawio file through the app's file picker */
|
||||
export async function openDrawioFile(page: Page, name: string, xml: string) {
|
||||
await page
|
||||
.locator('input[type="file"][accept^=".drawio"]')
|
||||
.first()
|
||||
.setInputFiles({
|
||||
name,
|
||||
mimeType: "application/xml",
|
||||
buffer: Buffer.from(xml),
|
||||
})
|
||||
}
|
||||
|
||||
/** Get the settings button */
|
||||
export function getSettingsButton(page: Page) {
|
||||
return page.locator('[data-testid="settings-button"]')
|
||||
@@ -60,20 +72,33 @@ export async function sendMessage(page: Page, message: string) {
|
||||
await chatInput.press("ControlOrMeta+Enter")
|
||||
}
|
||||
|
||||
/** Wait for diagram generation to complete */
|
||||
export async function waitForComplete(page: Page, timeout = 15000) {
|
||||
await expect(page.locator('text="Complete"')).toBeVisible({ timeout })
|
||||
/** Tool calls that finished (version cards and tool rows) */
|
||||
export function getCompletedTools(page: Page) {
|
||||
return page.locator('[data-tool-state="output-available"]')
|
||||
}
|
||||
|
||||
/** Wait for N "Complete" badges */
|
||||
/** Wait for diagram generation to complete */
|
||||
export async function waitForComplete(page: Page, timeout = 15000) {
|
||||
await expect(getCompletedTools(page).first()).toBeVisible({ timeout })
|
||||
}
|
||||
|
||||
/** Wait for N finished tool calls */
|
||||
export async function waitForCompleteCount(
|
||||
page: Page,
|
||||
count: number,
|
||||
timeout = 15000,
|
||||
) {
|
||||
await expect(page.locator('text="Complete"')).toHaveCount(count, {
|
||||
timeout,
|
||||
})
|
||||
await expect(getCompletedTools(page)).toHaveCount(count, { timeout })
|
||||
}
|
||||
|
||||
/** The file input behind the message box's "Add" menu */
|
||||
export function getAttachmentInput(page: Page) {
|
||||
return page.locator('[data-testid="attachment-input"]')
|
||||
}
|
||||
|
||||
/** The send button (icon only; found by its accessible name) */
|
||||
export function getSendButton(page: Page, name = "Send") {
|
||||
return page.getByRole("button", { name, exact: true })
|
||||
}
|
||||
|
||||
/** Wait for a specific text to appear */
|
||||
@@ -83,10 +108,19 @@ export async function waitForText(page: Page, text: string, timeout = 15000) {
|
||||
|
||||
/** Open settings dialog */
|
||||
export async function openSettings(page: Page) {
|
||||
await getSettingsButton(page).click()
|
||||
await getSettingsButton(page).first().click()
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/** Open settings on one of its pages: models, appearance, advanced, about */
|
||||
export async function openSettingsTab(
|
||||
page: Page,
|
||||
tab: "models" | "appearance" | "advanced" | "about",
|
||||
) {
|
||||
await openSettings(page)
|
||||
await page.locator(`[data-testid="settings-tab-${tab}"]`).click()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mock helpers
|
||||
// ============================================
|
||||
|
||||
@@ -48,7 +48,7 @@ test("the Test button checks all models at once and shows each result", async ({
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
await page.locator("button:has(svg.lucide-bot)").first().click()
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.getByText("Configure Models...").click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
@@ -75,7 +75,7 @@ test("the key link and the base URL cleanup", async ({ page }) => {
|
||||
}, CONFIG)
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await page.locator("button:has(svg.lucide-bot)").first().click()
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.getByText("Configure Models...").click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
|
||||
@@ -23,7 +23,7 @@ async function openQwenSettings(page: Page, config: object = CONFIG) {
|
||||
}, config)
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await page.locator("button:has(svg.lucide-bot)").first().click()
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.getByText("Configure Models...").click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
@@ -357,3 +357,73 @@ test("no spinner stays after another tab's change while elsewhere", async ({
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
||||
})
|
||||
|
||||
const savedConfig = (page: Page) =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(
|
||||
localStorage.getItem("next-ai-draw-io-model-configs") ?? "{}",
|
||||
),
|
||||
)
|
||||
|
||||
test("a test result that arrives after the settings closed is kept", async ({
|
||||
page,
|
||||
}) => {
|
||||
const release = await holdRoute(page, "**/api/validate-model", {
|
||||
valid: true,
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
release()
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await savedConfig(page)).providers[0].models[0].validated,
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test("a test result for a key changed on another settings tab is dropped", async ({
|
||||
page,
|
||||
}) => {
|
||||
const release = await holdRoute(page, "**/api/validate-model", {
|
||||
valid: true,
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
// The panel goes away while the test runs, then the key changes
|
||||
await page.locator('[data-testid="settings-tab-advanced"]').click()
|
||||
await page.locator('[data-testid="settings-tab-models"]').click()
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await dialog.locator("input[type=password]").first().fill("new-key")
|
||||
release()
|
||||
await page.waitForTimeout(800)
|
||||
const saved = await savedConfig(page)
|
||||
expect(saved.providers[0].apiKey).toBe("new-key")
|
||||
expect(saved.providers[0].models[0].validated).not.toBe(true)
|
||||
expect(saved.providers[0].validated).not.toBe(true)
|
||||
})
|
||||
|
||||
test("a failed test speaks the page's language", async ({ page }) => {
|
||||
await page.addInitScript((config) => {
|
||||
localStorage.setItem("next-ai-draw-io-locale", "zh")
|
||||
localStorage.setItem(
|
||||
"next-ai-draw-io-model-configs",
|
||||
JSON.stringify(config),
|
||||
)
|
||||
}, TWO_PROVIDERS)
|
||||
await page.route("**/api/validate-model", (route) => route.abort())
|
||||
await page.goto("/zh", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await page.getByTestId("settings-button").first().click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await dialog.getByRole("button", { name: "测试", exact: true }).click()
|
||||
await expect(dialog.getByText("1 个模型验证失败")).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByText("网络错误。请检查您的连接。").first(),
|
||||
).toBeAttached()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
|
||||
import {
|
||||
expect,
|
||||
getIframe,
|
||||
openSettings,
|
||||
openSettingsTab,
|
||||
test,
|
||||
} from "./lib/fixtures"
|
||||
|
||||
test.describe("Settings", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -12,14 +18,14 @@ test.describe("Settings", () => {
|
||||
})
|
||||
|
||||
test("language selection is available", async ({ page }) => {
|
||||
await openSettings(page)
|
||||
await openSettingsTab(page, "appearance")
|
||||
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await expect(dialog.locator('text="English"')).toBeVisible()
|
||||
await expect(dialog.locator("#language-select")).toHaveText(/English/)
|
||||
})
|
||||
|
||||
test("max output tokens is editable and persists", async ({ page }) => {
|
||||
await openSettings(page)
|
||||
await openSettingsTab(page, "advanced")
|
||||
|
||||
const input = page.locator("#max-output-tokens")
|
||||
await expect(input).toBeVisible()
|
||||
@@ -38,11 +44,14 @@ test.describe("Settings", () => {
|
||||
await expect(input).toHaveValue("12000")
|
||||
})
|
||||
|
||||
test("draw.io theme toggle exists", async ({ page }) => {
|
||||
await openSettings(page)
|
||||
test("theme can be light, dark or follow the system", async ({ page }) => {
|
||||
await openSettingsTab(page, "appearance")
|
||||
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
const themeText = dialog.locator("text=/sketch|minimal/i")
|
||||
await expect(themeText.first()).toBeVisible()
|
||||
for (const name of ["Light", "Dark", "System"]) {
|
||||
await expect(dialog.getByRole("radio", { name })).toBeVisible()
|
||||
}
|
||||
await dialog.getByRole("radio", { name: "Dark" }).click()
|
||||
await expect(page.locator("html")).toHaveClass(/dark/)
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ describe("exports in flight at the same time", () => {
|
||||
let tag = ""
|
||||
const got: string[] = []
|
||||
act(() => {
|
||||
tag = result.current.handleExportWithoutHistory()
|
||||
tag = result.current.handleExport()
|
||||
result.current.exportResolversRef.current[tag] = (xml) =>
|
||||
got.push(xml)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import pako from "pako"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { diffDiagrams, EMPTY_SUMMARY, isSameDocument } from "@/lib/diagram-diff"
|
||||
|
||||
const doc = (cells: string) =>
|
||||
`<mxfile><diagram name="Page-1" id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
const box = (id: string, value = id) =>
|
||||
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
|
||||
const edge = (id: string, source: string, target: string) =>
|
||||
`<mxCell id="${id}" edge="1" parent="1" source="${source}" target="${target}"><mxGeometry relative="1" as="geometry"/></mxCell>`
|
||||
|
||||
const pageOf = (name: string, cells: string, attrs = "") =>
|
||||
`<diagram name="${name}" id="${name}"><mxGraphModel${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
|
||||
const file = (...pages: string[]) => `<mxfile>${pages.join("")}</mxfile>`
|
||||
const packedPage = (name: string, cells: string) => {
|
||||
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
|
||||
const packed = Buffer.from(
|
||||
pako.deflateRaw(encodeURIComponent(model)),
|
||||
).toString("base64")
|
||||
return `<diagram name="${name}" id="${name}">${packed}</diagram>`
|
||||
}
|
||||
|
||||
describe("diffDiagrams", () => {
|
||||
it("counts added shapes and connectors on an empty canvas", () => {
|
||||
const { summary, touchedIds } = diffDiagrams(
|
||||
"",
|
||||
doc(box("a") + box("b") + edge("e", "a", "b")),
|
||||
)
|
||||
expect(summary.shapesAdded).toBe(2)
|
||||
expect(summary.edgesAdded).toBe(1)
|
||||
expect(touchedIds.sort()).toEqual(["a", "b", "e"])
|
||||
})
|
||||
|
||||
it("separates added, changed and removed cells", () => {
|
||||
const before = doc(box("a") + box("b") + edge("e", "a", "b"))
|
||||
const after = doc(box("a", "renamed") + box("c") + edge("e", "a", "c"))
|
||||
const { summary, touchedIds } = diffDiagrams(before, after)
|
||||
expect(summary).toEqual({
|
||||
shapesAdded: 1,
|
||||
shapesRemoved: 1,
|
||||
shapesChanged: 1,
|
||||
edgesAdded: 0,
|
||||
edgesRemoved: 0,
|
||||
edgesChanged: 1,
|
||||
})
|
||||
expect(touchedIds.sort()).toEqual(["a", "c", "e"])
|
||||
})
|
||||
|
||||
it("ignores how the same cell is written", () => {
|
||||
// As the model writes it, and as draw.io saves it
|
||||
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" style="rounded=1;html=1;" vertex="1" parent="1"><mxGeometry x="10" y="20" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
const saved = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" parent="1" style="html=1;rounded=1" value="A" vertex="1"><mxGeometry as="geometry" height="40" width="80" x="10" y="20"/></mxCell></root></mxGraphModel>`
|
||||
expect(diffDiagrams(model, saved).summary).toEqual(EMPTY_SUMMARY)
|
||||
})
|
||||
|
||||
it("reports nothing for identical diagrams", () => {
|
||||
const xml = doc(box("a"))
|
||||
const { summary, touchedIds } = diffDiagrams(xml, xml)
|
||||
expect(summary).toEqual(EMPTY_SUMMARY)
|
||||
expect(touchedIds).toEqual([])
|
||||
})
|
||||
|
||||
it("ignores values draw.io drops when it saves", () => {
|
||||
// The model writes defaults and long numbers; draw.io leaves them out
|
||||
const model = doc(
|
||||
`<mxCell id="a" value="A" style="" vertex="1" connectable="1" parent="1"><mxGeometry x="0" y="0.0" width="80.0" height="40" relative="0" as="geometry"/></mxCell><mxCell id="e" edge="1" parent="1" source="a" target="a"><mxGeometry relative="1" as="geometry"><mxPoint x="0" y="0" as="offset"/></mxGeometry></mxCell>`,
|
||||
)
|
||||
const saved = doc(
|
||||
`<mxCell id="a" value="A" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell><mxCell id="e" edge="1" parent="1" source="a" target="a"><mxGeometry relative="1" as="geometry"><mxPoint as="offset"/></mxGeometry></mxCell>`,
|
||||
)
|
||||
expect(diffDiagrams(model, saved).summary).toEqual(EMPTY_SUMMARY)
|
||||
expect(isSameDocument(model, saved)).toBe(true)
|
||||
})
|
||||
|
||||
it("reads styles as draw.io does: a later key wins", () => {
|
||||
const styled = (style: string) =>
|
||||
doc(
|
||||
`<mxCell id="a" style="${style}" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell>`,
|
||||
)
|
||||
const blueThenRed = styled("fillColor=#00f;fillColor=#f00")
|
||||
expect(
|
||||
isSameDocument(
|
||||
styled("fillColor=#f00;fillColor=#00f"),
|
||||
blueThenRed,
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
styled("fillColor=#f00;rounded=1"),
|
||||
blueThenRed.replace(
|
||||
"fillColor=#00f;fillColor=#f00",
|
||||
"rounded=1;fillColor=#f00",
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("reads the id from <UserObject> wrappers", () => {
|
||||
const wrapped = `<UserObject label="Linked" link="https://example.com" id="u1"><mxCell vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell></UserObject>`
|
||||
const { summary, touchedIds } = diffDiagrams(doc(""), doc(wrapped))
|
||||
expect(summary.shapesAdded).toBe(1)
|
||||
expect(touchedIds).toEqual(["u1"])
|
||||
})
|
||||
|
||||
it("only looks at the first page", () => {
|
||||
const twoPages = file(pageOf("p1", box("a")), pageOf("p2", box("z")))
|
||||
expect(diffDiagrams("", twoPages).summary.shapesAdded).toBe(1)
|
||||
})
|
||||
|
||||
it("reads compressed pages", () => {
|
||||
const xml = file(packedPage("p1", box("a") + box("b")))
|
||||
expect(diffDiagrams("", xml).summary.shapesAdded).toBe(2)
|
||||
expect(diffDiagrams(xml, xml).fromScratch).toBe(false)
|
||||
})
|
||||
|
||||
it("treats unparsable XML as empty", () => {
|
||||
expect(
|
||||
diffDiagrams("<mxfile><diagram>", doc(box("a"))).fromScratch,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSameDocument", () => {
|
||||
const a = file(pageOf("One", box("a")), pageOf("Two", box("b")))
|
||||
|
||||
it("is true for the same pages written differently", () => {
|
||||
const b = file(
|
||||
pageOf("One", box("a"), ' dx="300" dy="120" grid="1"'),
|
||||
packedPage("Two", box("b")),
|
||||
)
|
||||
expect(isSameDocument(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it("sees a change on the second page", () => {
|
||||
const b = file(pageOf("One", box("a")), pageOf("Two", box("b", "x")))
|
||||
expect(diffDiagrams(a, b).summary).toEqual(diffDiagrams(a, a).summary)
|
||||
expect(isSameDocument(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it("sees added pages, renamed pages and changed page settings", () => {
|
||||
expect(isSameDocument(a, file(pageOf("One", box("a"))))).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
a,
|
||||
file(pageOf("One", box("a")), pageOf("Renamed", box("b"))),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a"), ' background="#ffffff"')),
|
||||
file(pageOf("One", box("a"), ' background="#000000"')),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("compares a setting written on one side with draw.io's default", () => {
|
||||
const withAttrs = (attrs: string) =>
|
||||
file(pageOf("One", box("a"), attrs))
|
||||
const plain = withAttrs("")
|
||||
// Defaults, as draw.io fills them in (paper: Letter or A4)
|
||||
expect(
|
||||
isSameDocument(
|
||||
plain,
|
||||
withAttrs(' math="0" pageWidth="827" pageHeight="1169"'),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSameDocument(
|
||||
plain,
|
||||
withAttrs(' pageWidth="850" pageHeight="1100"'),
|
||||
),
|
||||
).toBe(true)
|
||||
// Real changes
|
||||
expect(isSameDocument(plain, withAttrs(' math="1"'))).toBe(false)
|
||||
expect(isSameDocument(plain, withAttrs(' shadow="1"'))).toBe(false)
|
||||
expect(isSameDocument(plain, withAttrs(' pageWidth="1654"'))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
isSameDocument(
|
||||
plain,
|
||||
withAttrs(
|
||||
' backgroundImage="{"src":"x.png"}"',
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees a background set on one side", () => {
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a"))),
|
||||
file(pageOf("One", box("a"), ' background="#000000"')),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees shapes brought to front or sent back", () => {
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a") + box("b"))),
|
||||
file(pageOf("One", box("b") + box("a"))),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees a hidden layer", () => {
|
||||
const hidden = file(pageOf("One", box("a"))).replace(
|
||||
'<mxCell id="1" parent="0"/>',
|
||||
'<mxCell id="1" parent="0" visible="0"/>',
|
||||
)
|
||||
expect(isSameDocument(file(pageOf("One", box("a"))), hidden)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not care where children of other parents are written", () => {
|
||||
const group = (inner: string) =>
|
||||
`<mxCell id="g" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>${inner}`
|
||||
const child = `<mxCell id="c" vertex="1" parent="g"><mxGeometry as="geometry"/></mxCell>`
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", group(child) + box("a"))),
|
||||
file(pageOf("One", group("") + box("a") + child)),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { formatSelectionContext } from "@/lib/chat-helpers"
|
||||
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
|
||||
import { withPageDefaults } from "@/lib/drawio/drawio-config"
|
||||
import { describeChanges } from "@/lib/version-text"
|
||||
import {
|
||||
MAX_VERSIONS,
|
||||
useVersionsStore,
|
||||
versionsFromLegacyHistory,
|
||||
} from "@/stores/versions-store"
|
||||
|
||||
describe("withPageDefaults", () => {
|
||||
it("adds page and grid when the model has none", () => {
|
||||
expect(withPageDefaults("<mxGraphModel><root/></mxGraphModel>")).toBe(
|
||||
'<mxGraphModel page="1" grid="1" gridSize="10"><root/></mxGraphModel>',
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps values the diagram already sets", () => {
|
||||
const xml = '<mxGraphModel dx="10" page="0" grid="0" gridSize="5">'
|
||||
expect(withPageDefaults(xml)).toBe(xml)
|
||||
})
|
||||
|
||||
it("reads attributes written with spaces around =", () => {
|
||||
const xml = '<mxGraphModel page = "0" grid ="0" gridSize= "5">'
|
||||
expect(withPageDefaults(xml)).toBe(xml)
|
||||
})
|
||||
|
||||
it("handles self-closing models and several pages", () => {
|
||||
const xml =
|
||||
'<mxfile><diagram><mxGraphModel/></diagram><diagram><mxGraphModel page="0"></mxGraphModel></diagram></mxfile>'
|
||||
expect(withPageDefaults(xml)).toBe(
|
||||
'<mxfile><diagram><mxGraphModel page="1" grid="1" gridSize="10"/></diagram><diagram><mxGraphModel page="0" grid="1" gridSize="10"></mxGraphModel></diagram></mxfile>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("describeChanges", () => {
|
||||
const en = {
|
||||
shapesAddedOne: "added 1 shape",
|
||||
shapesAddedOther: "added {count} shapes",
|
||||
edgesChangedOne: "changed 1 connector",
|
||||
edgesChangedOther: "changed {count} connectors",
|
||||
noChanges: "No visible changes",
|
||||
listSeparator: ", ",
|
||||
}
|
||||
|
||||
it("uses singular and plural forms and sentence case", () => {
|
||||
expect(
|
||||
describeChanges(
|
||||
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
|
||||
en,
|
||||
),
|
||||
).toBe("Added 2 shapes, changed 1 connector")
|
||||
})
|
||||
|
||||
it("says so when nothing changed", () => {
|
||||
expect(describeChanges({ ...EMPTY_SUMMARY }, en)).toBe(
|
||||
"No visible changes",
|
||||
)
|
||||
})
|
||||
|
||||
it("uses the language's list separator", () => {
|
||||
const zh = {
|
||||
shapesAddedOther: "新增 {count} 个图形",
|
||||
edgesChangedOne: "改了 1 条连线",
|
||||
listSeparator: ",",
|
||||
}
|
||||
expect(
|
||||
describeChanges(
|
||||
{ ...EMPTY_SUMMARY, shapesAdded: 3, edgesChanged: 1 },
|
||||
zh,
|
||||
),
|
||||
).toBe("新增 3 个图形,改了 1 条连线")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatSelectionContext", () => {
|
||||
it("lists selected ids with their labels", () => {
|
||||
const text = formatSelectionContext([
|
||||
{ id: "4", label: "EC2" },
|
||||
{ id: "cf", label: "" },
|
||||
])
|
||||
expect(text).toContain('- id="4" (EC2)')
|
||||
expect(text).toContain('- id="cf"')
|
||||
expect(text).not.toContain('id="cf" (')
|
||||
})
|
||||
|
||||
it("returns nothing for missing or malformed input", () => {
|
||||
expect(formatSelectionContext(undefined)).toBe("")
|
||||
expect(formatSelectionContext("4")).toBe("")
|
||||
expect(formatSelectionContext([{ label: "no id" }])).toBe("")
|
||||
})
|
||||
|
||||
it("strips quotes and newlines and limits length and count", () => {
|
||||
const many = Array.from({ length: 80 }, (_, i) => ({
|
||||
id: `c${i}`,
|
||||
label: `line\n"quoted"${"x".repeat(200)}`,
|
||||
}))
|
||||
const text = formatSelectionContext(many)
|
||||
const lines = text.split("\n").filter((l) => l.startsWith("- id="))
|
||||
expect(lines).toHaveLength(50)
|
||||
// The model is told the list is cut short
|
||||
expect(text).toContain("- and 30 more selected shapes not listed")
|
||||
expect(text).not.toContain('"quoted"')
|
||||
expect(lines[0].length).toBeLessThan(130)
|
||||
})
|
||||
})
|
||||
|
||||
describe("versions store", () => {
|
||||
beforeEach(() => useVersionsStore.getState().clear())
|
||||
|
||||
const add = (n: number) =>
|
||||
useVersionsStore.getState().addVersion({
|
||||
xml: `<x n="${n}"/>`,
|
||||
beforeXml: `<before n="${n}"/>`,
|
||||
turnIndex: n,
|
||||
summary: { ...EMPTY_SUMMARY },
|
||||
})
|
||||
|
||||
it("keeps the before-XML only on the newest version", () => {
|
||||
add(0)
|
||||
add(2)
|
||||
const [first, second] = useVersionsStore.getState().versions
|
||||
expect(first.beforeXml).toBeUndefined()
|
||||
expect(second.beforeXml).toBe('<before n="2"/>')
|
||||
})
|
||||
|
||||
it("keeps at most MAX_VERSIONS versions", () => {
|
||||
for (let i = 0; i < MAX_VERSIONS + 5; i++) add(i)
|
||||
const { versions } = useVersionsStore.getState()
|
||||
expect(versions).toHaveLength(MAX_VERSIONS)
|
||||
expect(versions[0].turnIndex).toBe(5)
|
||||
})
|
||||
|
||||
it("drops versions from a turn that is re-run, none left on the canvas", () => {
|
||||
add(0)
|
||||
add(2)
|
||||
add(4)
|
||||
useVersionsStore.getState().removeFromTurn(2)
|
||||
const state = useVersionsStore.getState()
|
||||
expect(state.versions.map((v) => v.turnIndex)).toEqual([0])
|
||||
expect(state.onCanvasVersionId).toBeNull()
|
||||
})
|
||||
|
||||
it("hand edits clear both version flags", () => {
|
||||
const id = add(0)
|
||||
expect(useVersionsStore.getState().onCanvasVersionId).toBe(id)
|
||||
useVersionsStore.getState().clearCanvasFlags()
|
||||
let state = useVersionsStore.getState()
|
||||
expect(state.onCanvasVersionId).toBeNull()
|
||||
expect(state.versions[0].onCanvas).toBeUndefined()
|
||||
useVersionsStore.getState().setUndone(id)
|
||||
useVersionsStore.getState().clearCanvasFlags()
|
||||
state = useVersionsStore.getState()
|
||||
expect(state.undoneVersionId).toBeNull()
|
||||
expect(state.versions[0].undone).toBeUndefined()
|
||||
})
|
||||
|
||||
it("converts the old history list", () => {
|
||||
const versions = versionsFromLegacyHistory([
|
||||
{ svg: "data:image/svg+xml;base64,AA", xml: "<a/>" },
|
||||
])
|
||||
expect(versions).toHaveLength(1)
|
||||
expect(versions[0]).toMatchObject({ xml: "<a/>", turnIndex: -1 })
|
||||
expect(versionsFromLegacyHistory(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { shortModelName } from "@/components/model-selector"
|
||||
|
||||
describe("shortModelName", () => {
|
||||
it("drops the provider or region prefix the logo already shows", () => {
|
||||
expect(shortModelName("nvidia/nemotron-3-ultra-550b-a55b:free")).toBe(
|
||||
"nemotron-3-ultra-550b-a55b:free",
|
||||
)
|
||||
expect(shortModelName("global.anthropic.claude-opus-5-5")).toBe(
|
||||
"claude-opus-5-5",
|
||||
)
|
||||
expect(shortModelName("us.amazon.nova-2-lite-v1:0")).toBe(
|
||||
"nova-2-lite-v1:0",
|
||||
)
|
||||
expect(shortModelName("openai.gpt-6-luna")).toBe("gpt-6-luna")
|
||||
expect(shortModelName("@tx/deepseek-ai/deepseek-v32")).toBe(
|
||||
"deepseek-v32",
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps the vendor when the rest does not name it", () => {
|
||||
expect(shortModelName("deepseek.r1-v1:0")).toBe("deepseek.r1-v1:0")
|
||||
expect(shortModelName("us.deepseek.r1-v1:0")).toBe("deepseek.r1-v1:0")
|
||||
expect(shortModelName("deepseek.v3-v1:0")).toBe("deepseek.v3-v1:0")
|
||||
})
|
||||
|
||||
it("keeps version numbers with dots", () => {
|
||||
for (const id of [
|
||||
"gpt-5.5",
|
||||
"glm-4.7",
|
||||
"gemini-2.5-flash",
|
||||
"kimi-k2.6",
|
||||
]) {
|
||||
expect(shortModelName(id)).toBe(id)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,10 @@
|
||||
import { cleanup, render } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||
import { ToolInputDetails } from "@/components/chat/tool-activity"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const dict = {
|
||||
tools: { complete: "Complete" },
|
||||
chat: { copied: "Copied", failedToCopy: "Failed", copyResponse: "Copy" },
|
||||
}
|
||||
|
||||
describe("ToolCallCard", () => {
|
||||
describe("ToolInputDetails", () => {
|
||||
it("shows streamed operations without crashing on broken entries", () => {
|
||||
// A partly streamed or invalid edit_diagram input
|
||||
const operations = [
|
||||
@@ -22,7 +17,7 @@ describe("ToolCallCard", () => {
|
||||
{ operation: "update", cell_id: "3", new_xml: '<mxCell id="3"/>' },
|
||||
]
|
||||
const { container } = render(
|
||||
<ToolCallCard
|
||||
<ToolInputDetails
|
||||
part={
|
||||
{
|
||||
type: "tool-edit_diagram",
|
||||
@@ -31,15 +26,25 @@ describe("ToolCallCard", () => {
|
||||
input: { operations },
|
||||
} as any
|
||||
}
|
||||
expandedTools={{ t1: true }}
|
||||
setExpandedTools={() => {}}
|
||||
onCopy={() => {}}
|
||||
copiedToolCallId={null}
|
||||
copyFailedToolCallId={null}
|
||||
dict={dict}
|
||||
/>,
|
||||
)
|
||||
expect(container.textContent).toContain("cell_id: 3")
|
||||
expect(container.textContent).toContain("update3")
|
||||
expect(container.textContent).toContain('<mxCell id="3"/>')
|
||||
})
|
||||
|
||||
it("ignores an xml input that is not text", () => {
|
||||
const { container } = render(
|
||||
<ToolInputDetails
|
||||
part={
|
||||
{
|
||||
type: "tool-display_diagram",
|
||||
toolCallId: "t2",
|
||||
state: "input-streaming",
|
||||
input: { xml: { broken: true } },
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container.textContent).toContain("broken")
|
||||
})
|
||||
})
|
||||
+9
-16
@@ -1,6 +1,5 @@
|
||||
import { render } from "@testing-library/react"
|
||||
import { renderHook } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import en from "@/lib/i18n/dictionaries/en.json"
|
||||
|
||||
const page = (cells: string) =>
|
||||
`<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
@@ -19,12 +18,8 @@ vi.mock("@/contexts/diagram-context", () => ({
|
||||
loadDiagram: vi.fn(() => null),
|
||||
}),
|
||||
}))
|
||||
vi.mock("@/hooks/use-dictionary", () => ({ useDictionary: () => en }))
|
||||
|
||||
import { ChatMessageDisplay } from "@/components/chat-message-display"
|
||||
|
||||
// jsdom has no layout
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
import { useDiagramStreaming } from "@/components/chat/use-diagram-streaming"
|
||||
|
||||
describe("the streaming preview of a second edit", () => {
|
||||
it("starts from the first edit's result", () => {
|
||||
@@ -51,15 +46,13 @@ describe("the streaming preview of a second edit", () => {
|
||||
],
|
||||
},
|
||||
] as any
|
||||
render(
|
||||
<ChatMessageDisplay
|
||||
messages={messages}
|
||||
setInput={() => {}}
|
||||
setFiles={() => {}}
|
||||
processedToolCallsRef={{ current: new Set() }}
|
||||
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
|
||||
status="streaming"
|
||||
/>,
|
||||
renderHook(() =>
|
||||
useDiagramStreaming({
|
||||
messages,
|
||||
processedToolCallsRef: { current: new Set() },
|
||||
editDiagramOriginalXmlRef,
|
||||
loadedMessageIdsRef: { current: new Set() },
|
||||
}),
|
||||
)
|
||||
expect(editDiagramOriginalXmlRef.current.get("edit-2")).toBe(
|
||||
AFTER_FIRST_EDIT,
|
||||
@@ -10,6 +10,7 @@ const box = (id: string) =>
|
||||
function setup(partialXml: string) {
|
||||
const refs = {
|
||||
partialXmlRef: { current: partialXml },
|
||||
continuationOriginalRef: { current: null },
|
||||
// A failed edit's preview is still on the canvas, its original kept
|
||||
editDiagramOriginalXmlRef: {
|
||||
current: new Map([["edit-1", "<mxfile>original</mxfile>"]]),
|
||||
@@ -19,30 +20,28 @@ function setup(partialXml: string) {
|
||||
chartXMLRef: { current: "" },
|
||||
}
|
||||
const onDisplayChart = vi.fn(
|
||||
(_xml: string, _skipValidation?: boolean): string | null => null,
|
||||
(
|
||||
_xml: string,
|
||||
_skipValidation?: boolean,
|
||||
_mode?: string,
|
||||
): string | null => null,
|
||||
)
|
||||
const { result } = renderHook(() =>
|
||||
useDiagramToolHandlers({
|
||||
...refs,
|
||||
onDisplayChart,
|
||||
onFetchChart: async () => "",
|
||||
onExport: () => {},
|
||||
enableVlmValidation: false,
|
||||
}),
|
||||
)
|
||||
const addToolOutput = vi.fn()
|
||||
const append = (xml: string) =>
|
||||
const call = (toolName: string, input: object, toolCallId = "call-1") =>
|
||||
result.current.handleToolCall(
|
||||
{
|
||||
toolCall: {
|
||||
toolCallId: "append-1",
|
||||
toolName: "append_diagram",
|
||||
input: { xml },
|
||||
},
|
||||
},
|
||||
{ toolCall: { toolCallId, toolName, input } },
|
||||
addToolOutput,
|
||||
)
|
||||
return { refs, onDisplayChart, addToolOutput, append }
|
||||
const append = (xml: string) => call("append_diagram", { xml }, "append-1")
|
||||
return { refs, onDisplayChart, addToolOutput, append, call }
|
||||
}
|
||||
|
||||
describe("the screenshot check and Stop", () => {
|
||||
@@ -57,13 +56,13 @@ describe("the screenshot check and Stop", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDiagramToolHandlers({
|
||||
partialXmlRef: { current: "" },
|
||||
continuationOriginalRef: { current: null },
|
||||
editDiagramOriginalXmlRef: { current: new Map() },
|
||||
processedToolCallsRef: { current: new Set() },
|
||||
validationRetryCountRef: opts.retryCount ?? { current: 0 },
|
||||
chartXMLRef: { current: "" },
|
||||
onDisplayChart: () => null,
|
||||
onFetchChart: async () => "",
|
||||
onExport: () => {},
|
||||
enableVlmValidation: true,
|
||||
captureValidationPng:
|
||||
opts.captureValidationPng ??
|
||||
@@ -202,3 +201,39 @@ describe("append_diagram and the stored previews", () => {
|
||||
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("display_diagram and undo", () => {
|
||||
it("commits the diagram as one undo step before any check", async () => {
|
||||
const { onDisplayChart, call } = setup("")
|
||||
await call("display_diagram", { xml: box("2") })
|
||||
expect(onDisplayChart.mock.calls.map((c) => c[2])).toEqual(["commit"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("a cut off drawing", () => {
|
||||
it("is replaced starting from the diagram before it", async () => {
|
||||
const { refs, onDisplayChart, call } = setup("")
|
||||
// Cut off: its preview stays, the diagram before it is kept
|
||||
await call(
|
||||
"display_diagram",
|
||||
{ xml: `${box("2")}<mxCell id="3"` },
|
||||
"d1",
|
||||
)
|
||||
expect(refs.continuationOriginalRef.current).toBe(
|
||||
"<mxfile>original</mxfile>",
|
||||
)
|
||||
// A later drawing (after failed calls undone to the cut off one)
|
||||
// first goes back to it, so its undo step starts there
|
||||
await call("display_diagram", { xml: box("4") }, "d2")
|
||||
expect(
|
||||
onDisplayChart.mock.calls.map(([xml, , mode]) => [
|
||||
xml === "<mxfile>original</mxfile>" ? "original" : "new",
|
||||
mode,
|
||||
]),
|
||||
).toEqual([
|
||||
["original", "revert"],
|
||||
["new", "commit"],
|
||||
])
|
||||
expect(refs.continuationOriginalRef.current).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user