import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; import type { AIConfig } from "../../frontend/src/types"; const initialConfig: AIConfig = { enabled: true, provider: "local", base_url: "http://models.test/v1", model: "model-a", api_key_set: true, updated_at: "2026-11-01T00:00:00Z", }; async function openSettings( page: Page, config: AIConfig | null = initialConfig, ) { await page.route("**/api/session", (route) => route.fulfill({ json: { csrf: "test-csrf", user: { id: 1, name: "测试管理员", role: "admin", organization: "测试组织", departments: [], }, ai_enabled: true, }, }), ); await page.route("**/api/datasets", (route) => route.fulfill({ json: { items: [] } }), ); await page.goto("/"); if ((page.viewportSize()?.width || 1280) < 700) await page.getByLabel("展开导航", { exact: true }).click(); await page.getByRole("button", { name: "AI 模型", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "AI 模型配置" }); await expect(dialog).toBeVisible(); if (config) await expect(dialog.getByLabel("模型", { exact: true })).toHaveValue( config.model, ); return dialog; } test("保存成功立即提示,不等待模型列表返回", async ({ page }) => { await page.route("**/api/ai-config", (route) => route.fulfill({ json: { ok: true, ...initialConfig } }), ); let releaseModels!: () => void; const modelsPending = new Promise((resolve) => { releaseModels = resolve; }); await page.route("**/api/ai-models", async (route) => { await modelsPending; await route.fulfill({ json: { items: ["model-a", "model-b"] } }); }); const dialog = await openSettings(page); await dialog.getByLabel("API Key", { exact: true }).fill("test-key"); try { await dialog.getByRole("button", { name: "保存配置", exact: true }).click(); await expect( dialog.getByRole("status").filter({ hasText: "配置已保存" }), ).toBeVisible({ timeout: 2000, }); await expect( dialog.getByRole("status").filter({ hasText: "配置已保存" }), ).toBeInViewport({ ratio: 1 }); await expect(dialog.getByLabel("API Key", { exact: true })).toHaveValue(""); await expect(dialog.getByRole("button", { name: "保存中…" })).toHaveCount( 0, ); } finally { releaseModels(); } }); test("保存失败显示原因并保留 Key 和未保存的模型", async ({ page }) => { await page.route("**/api/ai-config", (route) => route.request().method() === "PUT" ? route.fulfill({ status: 400, json: { error: "Base URL 无效" } }) : route.fulfill({ json: initialConfig }), ); await page.route("**/api/ai-models", (route) => route.fulfill({ json: { items: ["model-a", "model-b"] } }), ); const dialog = await openSettings(page); await dialog.getByLabel("API Key", { exact: true }).fill("test-new-key"); await dialog.getByLabel("模型", { exact: true }).fill("model-b"); await dialog.getByRole("button", { name: "保存配置", exact: true }).click(); await expect(dialog.getByRole("alert")).toContainText( "保存失败:Base URL 无效", ); await expect(dialog.getByLabel("API Key", { exact: true })).toHaveValue( "test-new-key", ); await expect(dialog.getByLabel("模型", { exact: true })).toHaveValue( "model-b", ); await expect( dialog.getByRole("region", { name: "已保存的配置" }), ).toContainText("当前使用的模型:model-a"); await expect( dialog.getByRole("status").filter({ hasText: "配置已保存" }), ).toHaveCount(0); await expect( dialog.getByRole("button", { name: "保存配置", exact: true }), ).toBeEnabled(); }); test("可选模型直接列出,选择后保存才生效,重新打开仍保留", async ({ page }) => { await page.setViewportSize({ width: 1512, height: 1100 }); let savedConfig = { ...initialConfig }; const saves: Array> = []; await page.route("**/api/ai-config", (route) => { if (route.request().method() === "PUT") { const data = route.request().postDataJSON(); saves.push(data); savedConfig = { ...savedConfig, enabled: data.enabled, provider: data.provider, base_url: data.base_url, model: data.model, api_key_set: Boolean(data.api_key) || savedConfig.api_key_set, updated_at: "2026-11-01T00:01:00Z", }; } return route.fulfill({ json: { ok: true, ...savedConfig } }); }); await page.route("**/api/ai-models", (route) => route.fulfill({ json: { items: ["model-a", "model-b"] } }), ); const dialog = await openSettings(page); const options = dialog.getByRole("region", { name: "可选模型" }); const summary = dialog.getByRole("region", { name: "已保存的配置" }); await expect( options.getByRole("radio", { name: "model-a", exact: true }), ).toBeChecked(); await expect( options.getByRole("radio", { name: "model-b", exact: true }), ).toBeVisible(); await expect(summary).toContainText("当前使用的模型:model-a"); await options.getByRole("radio", { name: "model-b", exact: true }).check(); await expect(dialog.getByLabel("模型", { exact: true })).toHaveValue( "model-b", ); await expect(options).toContainText("待保存"); await expect(summary).toContainText("当前使用的模型:model-a"); expect(saves).toHaveLength(0); await dialog.getByRole("button", { name: "保存配置", exact: true }).click(); await expect(summary).toContainText("当前使用的模型:model-b"); await expect(options).not.toContainText("待保存"); expect(saves).toHaveLength(1); expect(saves[0]).toMatchObject({ model: "model-b", api_key: "" }); await page.screenshot({ path: test.info().outputPath("ai-settings-success.png"), fullPage: true, }); await dialog.getByLabel("关闭", { exact: true }).click(); await page.getByRole("button", { name: "AI 模型", exact: true }).click(); await expect(summary).toContainText("当前使用的模型:model-b"); await expect( options.getByRole("radio", { name: "model-b", exact: true }), ).toBeChecked(); await expect(dialog.getByLabel("API Key", { exact: true })).toHaveValue(""); await expect(dialog.getByLabel("API Key", { exact: true })).toHaveAttribute( "placeholder", "已配置,留空保持不变", ); }); test("拉取模型失败仍保留保存成功结果和已保存模型", async ({ page }) => { let saved = false; await page.route("**/api/ai-config", (route) => { if (route.request().method() === "PUT") saved = true; return route.fulfill({ json: { ok: true, ...initialConfig } }); }); await page.route("**/api/ai-models", (route) => saved ? route.fulfill({ status: 502, json: { error: "无法连接模型服务" } }) : route.fulfill({ json: { items: ["model-a", "model-b"] } }), ); const dialog = await openSettings(page); await expect( dialog.getByRole("radio", { name: "model-b", exact: true }), ).toBeVisible(); await dialog.getByRole("button", { name: "保存配置", exact: true }).click(); await expect( dialog.getByRole("status").filter({ hasText: "配置已保存" }), ).toBeVisible(); await expect(dialog.getByRole("alert")).toContainText( "获取模型列表失败:无法连接模型服务", ); await expect(dialog.getByRole("alert")).not.toContainText("保存失败"); await expect( dialog.getByRole("region", { name: "已保存的配置" }), ).toContainText("当前使用的模型:model-a"); await expect( dialog.getByRole("button", { name: "保存配置", exact: true }), ).toBeEnabled(); }); test("OpenAI 默认地址也会加载列表,停用的已保存模型不会显示为当前使用", async ({ page, }) => { const config: AIConfig = { ...initialConfig, enabled: false, provider: "openai", base_url: "", }; let modelRequests = 0; await page.route("**/api/ai-config", (route) => route.fulfill({ json: { ok: true, ...config } }), ); await page.route("**/api/ai-models", (route) => { modelRequests++; return route.fulfill({ json: { items: ["model-a", "model-b"] } }); }); const dialog = await openSettings(page, config); const summary = dialog.getByRole("region", { name: "已保存的配置" }); await expect(summary).toContainText("已保存的模型:model-a"); await expect(summary).not.toContainText("当前使用的模型"); await expect( dialog.getByRole("radio", { name: "model-b", exact: true }), ).toBeVisible(); const requestsBeforeSave = modelRequests; await dialog.getByRole("button", { name: "保存配置", exact: true }).click(); await expect( dialog.getByRole("status").filter({ hasText: "配置已保存" }), ).toBeVisible(); await expect.poll(() => modelRequests).toBeGreaterThan(requestsBeforeSave); }); test("初次加载配置失败时禁止保存,不覆盖已有配置", async ({ page }) => { let saves = 0; await page.route("**/api/ai-config", (route) => { if (route.request().method() === "PUT") saves++; return route.fulfill({ status: 503, json: { error: "服务暂时不可用" } }); }); const dialog = await openSettings(page, null); await expect(dialog.getByRole("alert")).toContainText( "加载配置失败:服务暂时不可用", ); await expect( dialog.getByRole("button", { name: "保存配置", exact: true }), ).toBeDisabled(); await expect(dialog.getByLabel("API Key", { exact: true })).toBeDisabled(); await expect(dialog.getByLabel("模型", { exact: true })).toBeDisabled(); await expect( dialog.getByRole("region", { name: "已保存的配置" }), ).toHaveCount(0); expect(saves).toBe(0); }); test("清空本地地址并停用后保存,忽略旧服务延迟返回的模型", async ({ page }) => { let savedConfig = { ...initialConfig }; await page.route("**/api/ai-config", (route) => { if (route.request().method() === "PUT") { const { enabled, provider, base_url, model } = route .request() .postDataJSON(); savedConfig = { ...savedConfig, enabled, provider, base_url, model }; } return route.fulfill({ json: { ok: true, ...savedConfig } }); }); let releaseModels!: () => void; const modelsPending = new Promise((resolve) => { releaseModels = resolve; }); let modelRequests = 0; await page.route("**/api/ai-models", async (route) => { modelRequests++; await modelsPending; await route.fulfill({ json: { items: ["obsolete-model"] } }); }); const dialog = await openSettings(page); try { await expect.poll(() => modelRequests).toBe(1); await dialog .getByRole("checkbox", { name: "启用 AI 模型理解问题" }) .uncheck(); await dialog.getByLabel("Base URL", { exact: true }).fill(""); await dialog.getByLabel("模型", { exact: true }).fill(""); await dialog.getByRole("button", { name: "保存配置", exact: true }).click(); await expect( dialog.getByRole("status").filter({ hasText: "配置已保存" }), ).toBeVisible(); const response = page.waitForResponse("**/api/ai-models"); releaseModels(); await (await response).finished(); await page.evaluate(async () => { await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame); }); const options = dialog.getByRole("region", { name: "可选模型" }); await expect(options.getByRole("radio")).toHaveCount(0); await expect( options.getByRole("button", { name: "获取可用模型" }), ).toBeDisabled(); await expect(options).not.toContainText("正在获取可用模型"); expect(modelRequests).toBe(1); } finally { releaseModels(); } }); for (const field of ["Base URL", "API Key"]) { test(`修改 ${field} 后须先保存,旧服务模型不可选`, async ({ page }) => { await page.route("**/api/ai-config", (route) => route.fulfill({ json: initialConfig }), ); await page.route("**/api/ai-models", (route) => route.fulfill({ json: { items: ["model-a", "model-b"] } }), ); const dialog = await openSettings(page); const options = dialog.getByRole("region", { name: "可选模型" }); await expect( options.getByRole("radio", { name: "model-b", exact: true }), ).toBeVisible(); await dialog .getByLabel(field, { exact: true }) .fill( field === "Base URL" ? "http://another-models.test/v1" : "test-changed-key", ); await expect(options).toContainText( "服务配置已修改,请先保存后获取可选模型。", ); await expect(options.getByRole("radio")).toHaveCount(0); await expect( options.getByRole("button", { name: "获取可用模型" }), ).toBeDisabled(); await expect( dialog.getByRole("region", { name: "已保存的配置" }), ).toContainText("当前使用的模型:model-a"); await dialog .getByLabel(field, { exact: true }) .fill(field === "Base URL" ? initialConfig.base_url : ""); await expect( options.getByRole("radio", { name: "model-a", exact: true }), ).toBeChecked(); await expect( options.getByRole("button", { name: "获取可用模型" }), ).toBeEnabled(); }); } test("桌面和手机长模型名无横向溢出", async ({ page }) => { const longModel = `finance-analysis-${"long-model-name-".repeat(10)}2026`; const config = { ...initialConfig, model: longModel }; await page.route("**/api/ai-config", (route) => route.fulfill({ json: config }), ); await page.route("**/api/ai-models", (route) => route.fulfill({ json: { items: [longModel, "model-b"] } }), ); await page.setViewportSize({ width: 1512, height: 1100 }); const dialog = await openSettings(page, config); const options = dialog.getByRole("region", { name: "可选模型" }); await expect( options.getByRole("radio", { name: longModel, exact: true }), ).toBeVisible(); await page.screenshot({ path: test.info().outputPath("ai-settings-desktop.png"), fullPage: true, }); await dialog.getByLabel("关闭", { exact: true }).click(); await page.setViewportSize({ width: 390, height: 844 }); await page.getByLabel("展开导航", { exact: true }).click(); await page.getByRole("button", { name: "AI 模型", exact: true }).click(); const selected = options.getByRole("radio", { name: longModel, exact: true }); await expect(selected).toBeChecked(); await selected.scrollIntoViewIfNeeded(); expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); expect(await dialog.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe( true, ); expect(await options.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe( true, ); await dialog.getByRole("button", { name: "保存配置", exact: true }).click(); await expect( dialog.getByRole("status").filter({ hasText: "配置已保存" }), ).toBeInViewport({ ratio: 1 }); await page.screenshot({ path: test.info().outputPath("ai-settings-mobile.png"), fullPage: true, }); });