Files
next-ai-draw-io/tests/e2e/provider-models.spec.ts
T
dayuan.jiang 2e84e02666 fix(chat): keep the canvas after an unrelated error, and more review fixes
Found by the second PR review:
- After a streamed edit, an older render of the stream stored the edit's
  original diagram again, and the next failed request (no quota, a lost
  connection) put that old diagram back on the canvas. The tool handler
  now marks its call as handled, so the preview code leaves it alone.
- An edit applied before the UI showed an earlier broken edit's error was
  erased when that error undid its preview, or was built on that preview.
  The handler now starts from the diagram before all unhandled previews,
  and reads the diagram state that updates at once.
- A failed or stopped display_diagram left its half drawn diagram on the
  canvas. Its preview is undone now, like an edit's.
- "New chat" cleared a chat that could not be saved (storage full).
- The settings dialog showed a model list, a fetch error or a test result
  on the provider that was opened after the request started, and marked a
  model id changed during the test as tested.
- A tool call with broken JSON was shown as cut off by the output limit.
- History entries and session thumbnails could pair with a later diagram
  when draw.io answered an export late.
- A server model saved before non-ASCII provider names got into the id
  was reset to the default model.
2026-10-05 10:52:03 +09:00

235 lines
7.8 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test"
import { getIframe } from "./lib/fixtures"
// qwen-mt-plus is a translation model; models.dev lists no tool calls for it
const CONFIG = {
version: 1,
providers: [
{
id: "p1",
provider: "qwen",
apiKey: "test-key",
models: [{ id: "m1", modelId: "qwen-mt-plus" }],
},
],
}
async function openQwenSettings(page: Page, config: object = CONFIG) {
await page.addInitScript((config) => {
localStorage.setItem(
"next-ai-draw-io-model-configs",
JSON.stringify(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.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click()
return dialog
}
test("fetches the provider's models and adds one from the picker", async ({
page,
}) => {
let request: Record<string, unknown> | undefined
await page.route("**/api/provider-models", async (route) => {
request = route.request().postDataJSON()
await route.fulfill({
json: {
models: [
{ id: "qwen-new-max", tools: true },
{ id: "qwen-text-only", tools: false },
],
},
})
})
const dialog = await openQwenSettings(page)
await expect(
dialog.getByText("may not be able to draw").first(),
).toBeVisible()
await dialog
.getByRole("button", { name: "Fetch models from the provider" })
.click()
const picker = page.locator('[role="listbox"]')
await expect(picker.getByText("qwen-new-max")).toBeVisible()
await expect(
picker.getByRole("option", { name: /qwen-text-only/ }),
).toContainText("no tool calls")
expect(request).toMatchObject({ provider: "qwen", apiKey: "test-key" })
await page.getByPlaceholder("Search models...").fill("new-max")
await picker.getByText("qwen-new-max").click()
await expect(dialog.locator('input[title="qwen-new-max"]')).toBeVisible()
})
test("the model picker scrolls with the mouse wheel", async ({ page }) => {
// The picker sits in a popover above the settings dialog, which blocks
// wheel events outside itself
await page.route("**/api/provider-models", (route) =>
route.fulfill({
json: {
models: Array.from({ length: 60 }, (_, i) => ({
id: `qwen-model-${i}`,
})),
},
}),
)
const dialog = await openQwenSettings(page)
await dialog
.getByRole("button", { name: "Fetch models from the provider" })
.click()
const list = page.locator("[cmdk-list]")
await expect(list.getByText("qwen-model-0")).toBeVisible()
await list.hover()
await page.mouse.wheel(0, 400)
await expect
.poll(() => list.evaluate((el) => el.scrollTop), { timeout: 3000 })
.toBeGreaterThan(0)
})
test("shows a hint when the provider rejects the key", async ({ page }) => {
await page.route("**/api/provider-models", (route) =>
route.fulfill({
status: 401,
json: { code: "invalid_api_key", error: "Incorrect API key" },
}),
)
const dialog = await openQwenSettings(page)
await dialog
.getByRole("button", { name: "Fetch models from the provider" })
.click()
await expect(
dialog.getByText(
"The provider rejected the API key. Check it in model settings. Incorrect API key",
),
).toBeVisible()
})
test("a fetch error stays with its provider", async ({ page }) => {
await page.route("**/api/provider-models", (route) =>
route.fulfill({
status: 401,
json: { code: "invalid_api_key", error: "Incorrect API key" },
}),
)
const dialog = await openQwenSettings(page, {
version: 1,
providers: [
...CONFIG.providers,
{ id: "p2", provider: "glm", apiKey: "k", models: [] },
],
})
await dialog
.getByRole("button", { name: "Fetch models from the provider" })
.click()
const error = dialog.getByText("Incorrect API key")
await expect(error).toBeVisible()
await dialog.getByText("GLM (Zhipu)").first().click()
await expect(error).toHaveCount(0)
})
test("editing a model id clears the old test warning", async ({ page }) => {
const warning = "Connected, but the model answered without calling a tool."
const dialog = await openQwenSettings(page, {
version: 1,
providers: [
{
...CONFIG.providers[0],
models: [
{
id: "m1",
modelId: "qwen-max",
validated: true,
validationWarning: warning,
},
],
},
],
})
await expect(dialog.getByText(warning)).toBeVisible()
const input = dialog.locator('input[title="qwen-max"]')
await input.fill("qwen-mt-plus")
await input.blur()
await expect(dialog.getByText(warning)).toHaveCount(0)
await expect(dialog.getByText("may not be able to draw")).toBeVisible()
})
/** Hold requests to an endpoint until release() answers them with json */
async function holdRoute(page: Page, url: string, json: object) {
let release!: () => void
const released = new Promise<void>((r) => {
release = r
})
await page.route(url, async (route) => {
await released
await route.fulfill({ status: 200, json })
})
return release
}
const TWO_PROVIDERS = {
version: 1,
providers: [
{
...CONFIG.providers[0],
models: [{ id: "m1", modelId: "qwen-max" }],
},
{ id: "p2", provider: "glm", apiKey: "k", models: [] },
],
}
test("a model list that arrives after switching provider stays with its provider", async ({
page,
}) => {
const release = await holdRoute(page, "**/api/provider-models", {
code: "invalid_api_key",
error: "Incorrect API key",
})
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog
.getByRole("button", { name: "Fetch models from the provider" })
.click()
await dialog.getByText("GLM (Zhipu)").first().click()
release()
await page.waitForTimeout(500)
await expect(dialog.getByText("Incorrect API key")).toHaveCount(0)
})
test("a test result that arrives after switching provider stays with its provider", async ({
page,
}) => {
const release = await holdRoute(page, "**/api/validate-model", {
valid: false,
error: "Model not found",
})
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click()
await dialog.getByText("GLM (Zhipu)").first().click()
release()
await page.waitForTimeout(500)
await expect(dialog.getByText(/model\(s\) failed validation/)).toHaveCount(
0,
)
})
test("a test result does not count for a model id changed meanwhile", 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()
const input = dialog.locator('input[title="qwen-max"]')
await input.fill("qwen-plus")
await input.blur()
release()
await page.waitForTimeout(500)
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(0)
})