Files
next-ai-draw-io/tests/e2e/chat.spec.ts
T
dayuan.jiang b8b851fc5b fix: what the whole-PR review and Copilot found
- A redirect followed for a custom base URL also drops the key headers of
  providers that do not use Authorization (x-api-key, x-goog-api-key,
  api-key) when it goes to another origin.
- A second Enter or click while a message is being prepared (attachments
  read, diagram exported) no longer sends it twice.
- The admin Test on the deployment's own endpoints (EdgeOne, the server's
  keyless Ollama, an address on the server's network) counts toward the
  quota like a chat; the chat and the Test share one rule for it. The
  Test of an Azure entry set up by AZURE_RESOURCE_NAME only goes where chat
  goes.
- EdgeOne's function is called at the site root again, as on main: EdgeOne
  serves edge functions there, outside Next's base path.
- MCP History: the state before a write is kept unless the browser saved
  no change of the user's since the last server write (draw.io's sync copy
  of it adds no entry), and the dedupe compares the exact text again, so a
  change of page size or other settings only is its own version.
- MCP: an edit keeps untouched labels as draw.io shows them (a literal line
  break in an attribute is a space); a new document of empty pages the
  user named is auto-saved; load_diagram reads only regular files, so a
  pipe cannot hold up the other write tools; the preview does not load
  back its own push still on its way (an undo made meanwhile is saved).
- Two overlapping saves of a new chat no longer reload the canvas from the
  older copy.
- At most three screenshot checks per user turn, passed or failed, as
  documented.
- Desktop: the main window navigates only within the app (draw.io stays in
  its frame); a presets file that is not JSON and cannot be moved aside is
  not overwritten.
- A last self-closing cell with a raw "<" in a value is not taken for cut
  off output.
- README: Material Design shapes load their icons from fonts.gstatic.com.
2026-10-05 21:19:32 +09:00

136 lines
5.2 KiB
TypeScript

import {
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
import { createTextOnlyResponse } from "./lib/helpers"
test.describe("Chat Panel", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("page has interactive elements", async ({ page }) => {
const buttons = page.locator("button")
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
})
test("draw.io iframe is interactive", async ({ page }) => {
const iframe = getIframe(page)
await expect(iframe).toBeVisible()
const src = await iframe.getAttribute("src")
expect(src).toBeTruthy()
})
})
test.describe("Crossing the mobile breakpoint", () => {
test.beforeEach(async ({ page }) => {
// A text answer that arrives in parts over a few seconds
await page.addInitScript(() => {
const realFetch = window.fetch
window.fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input)
if (!url.endsWith("/api/chat")) return realFetch(input, init)
const events = [
{ type: "start" },
{ type: "text-start", id: "t" },
{ type: "text-delta", id: "t", delta: "Once upon" },
{ type: "text-delta", id: "t", delta: " a time." },
{ type: "text-end", id: "t" },
{ type: "finish" },
]
const body = new ReadableStream({
async start(controller) {
for (const event of events) {
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify(event)}\n\n`,
),
)
await new Promise((r) => setTimeout(r, 1500))
}
controller.enqueue(
new TextEncoder().encode("data: [DONE]\n\n"),
)
controller.close()
},
})
return new Response(body, {
headers: { "content-type": "text/event-stream" },
})
}
})
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
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,
})
await page.setViewportSize({ width: 600, height: 900 })
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()
await expect(getChatInput(page)).toBeHidden()
await page.setViewportSize({ width: 600, height: 900 })
await expect(getChatInput(page)).toBeVisible()
await page.setViewportSize({ width: 1280, height: 800 })
await expect(getChatInput(page)).toBeVisible()
})
})
test.describe("Sending", () => {
test("a double Enter sends the message once", async ({ page }) => {
let requests = 0
await page.route("**/api/chat", async (route) => {
requests++
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createTextOnlyResponse("Hello there."),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const input = getChatInput(page)
await input.fill("Hi")
// The second press comes while the diagram is being exported
await input.press("ControlOrMeta+Enter")
await input.press("ControlOrMeta+Enter")
await expect(page.getByText("Hello there.")).toBeVisible({
timeout: 10000,
})
await page.waitForTimeout(1500)
expect(requests).toBe(1)
await expect(page.getByText("Hello there.")).toHaveCount(1)
})
})