From d3e9e481febb6dc8e5ed745b283d9d60ffd5ba14 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 9 Aug 2026 09:46:48 +0800 Subject: [PATCH] feat(e2e): add agent chat E2E tests with mock LLM server - Add mock LLM server (OpenAI-compatible SSE streaming) on port 3600 - Inline mock LLM logic into global-setup to avoid ESM .ts import issues - Seed LLM provider + model + userConfig in global-setup - Add 10 agent chat E2E tests covering welcome page, input, send, streaming, history - Fix stdio pipe for execSync in global-setup to prevent Playwright hang - Login helper clicks new-chat button to avoid cross-test session pollution - All 37 E2E tests pass (smoke 5 + auth 22 + agent-chat 10) Co-authored-by: CodeFree --- e2e/global-setup.ts | 144 +++++++++++++++++++++++++++++++++++++++++-- e2e/global-teardown.ts | 3 + e2e/mock-llm-server.ts | 125 +++++++++++++++++++++++++++++++++++++ e2e/specs/agent-chat.spec.ts | 130 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 e2e/mock-llm-server.ts create mode 100644 e2e/specs/agent-chat.spec.ts diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index b5f77b6..6be0efd 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,10 +1,79 @@ import { execSync, spawn } from "node:child_process"; import { existsSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import path from "node:path"; +import { createClient } from "@libsql/client"; +import { createServer, type Server } from "node:http"; const ROOT = path.resolve(import.meta.dirname, ".."); const OUTPUT_DIR = path.join(ROOT, ".output"); const TEST_DB = path.join(ROOT, "packages", "drizzle-pkg", "db.test.sqlite"); +const MOCK_LLM_PORT = Number(process.env.MOCK_LLM_PORT) || 3600; +const MOCK_REPLY = "你好!这是一个测试回复。"; +let mockLlmServer: Server | null = null; + +function startMockLlm(): Promise { + return new Promise((resolve) => { + mockLlmServer = createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Headers", "*"); + res.setHeader("Access-Control-Allow-Methods", "*"); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + const url = req.url || ""; + if (url.startsWith("/v1/chat/completions") && req.method === "POST") { + let body = ""; + req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); + req.on("end", () => { + let parsed: any = {}; + try { parsed = JSON.parse(body); } catch { parsed = {}; } + const model = parsed.model || "mock-model"; + const chars = Array.from(MOCK_REPLY); + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + let idx = 0; + const timer = setInterval(() => { + if (idx < chars.length) { + const sseData = JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { content: chars[idx] }, finish_reason: null }], + }); + res.write(`data: ${sseData}\n\n`); + idx++; + } else { + const doneData = JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + res.write(`data: ${doneData}\n\n`); + res.write("data: [DONE]\n\n"); + res.end(); + clearInterval(timer); + } + }, 20); + }); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); + mockLlmServer.listen(MOCK_LLM_PORT, () => { + log(`[mock-llm] listening on http://localhost:${MOCK_LLM_PORT}`); + resolve(); + }); + }); +} function log(msg: string) { console.log(`[global-setup] ${msg}`); @@ -47,9 +116,10 @@ async function main() { log("Building Nuxt app with test env..."); execSync("bun run build", { cwd: ROOT, - stdio: "inherit", + stdio: "pipe", env: { ...process.env }, }); + log("Build complete."); // 4. 在 .output 中运行迁移 log("Running migrations..."); @@ -57,19 +127,20 @@ async function main() { const migrationsDir = path.join(OUTPUT_DIR, "migrations"); execSync(`node "${migrateScript}" "${migrationsDir}"`, { cwd: OUTPUT_DIR, - stdio: "inherit", + stdio: "pipe", env: { ...process.env, DATABASE_URL: `file:${TEST_DB}`, }, }); + log("Migrations complete."); // 5. 在 .output 中运行 seed(创建管理员账号) log("Running seed..."); const seedScript = path.join(OUTPUT_DIR, "server", "seed", "sqlite3.js"); execSync(`node "${seedScript}"`, { cwd: OUTPUT_DIR, - stdio: "inherit", + stdio: "pipe", env: { ...process.env, DATABASE_URL: `file:${TEST_DB}`, @@ -77,8 +148,16 @@ async function main() { BOOTSTRAP_ADMIN_PASSWORD: process.env.BOOTSTRAP_ADMIN_PASSWORD || "e2e_test_pass_123", }, }); + log("Seed complete."); + + // 6. 启动 mock LLM 服务器 + log(`Starting mock LLM server on port ${MOCK_LLM_PORT}...`); + await startMockLlm(); + // 7. 向测试数据库插入 LLM provider + model + 用户配置 + log("Seeding LLM provider, model, and user config..."); + await seedLlmData(TEST_DB); - // 6. 启动 preview 服务器 + // 8. 启动 preview 服务器 log("Starting preview server..."); const serverProcess = spawn("node", ["server/index.mjs"], { cwd: OUTPUT_DIR, @@ -101,7 +180,7 @@ async function main() { log(`[server:err] ${data.toString().trim()}`); }); - // 7. 等待服务器就绪 + // 9. 等待服务器就绪 const PORT = process.env.NITRO_PORT || "3400"; const BASE_URL = `http://localhost:${PORT}`; log(`Waiting for server at ${BASE_URL}...`); @@ -124,11 +203,64 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, retryInterval)); } - // 8. 保存 server PID 供 teardown 使用 + // 10. 保存 server PID 供 teardown 使用 const pidFile = path.join(ROOT, ".e2e-server-pid"); writeFileSync(pidFile, String(serverProcess.pid)); log("Global setup complete!"); } +/** + * 向测试数据库插入 LLM provider、model 和用户配置, + * 使 agent chat 能通过 mock LLM 服务器完成对话。 + */ +async function seedLlmData(dbPath: string) { + const client = createClient({ url: `file:${dbPath}` }); + + try { + // 插入 LLM provider(userId = 1,即 bootstrap admin) + await client.execute({ + sql: `INSERT INTO llm_providers (user_id, name, slug, base_url, parse_mode, api_key, status) + VALUES (1, 'Mock Provider', 'mock-provider', ?, 'openai-compatible', 'mock-api-key', 'active')`, + args: [`http://localhost:${MOCK_LLM_PORT}/v1`], + }); + + // 获取刚插入的 provider id + const providerRs = await client.execute( + `SELECT id FROM llm_providers WHERE slug = 'mock-provider' AND user_id = 1 LIMIT 1`, + ); + const providerId = providerRs.rows[0]?.id; + if (!providerId) { + throw new Error("Failed to insert mock LLM provider"); + } + + // 插入 LLM model + await client.execute({ + sql: `INSERT INTO llm_models (provider_id, name, model_id, type, enabled, supports_tools, max_tokens) + VALUES (?, 'Mock Model', 'mock-model', 'text', 1, 0, 1024)`, + args: [Number(providerId)], + }); + + // 获取刚插入的 model id + const modelRs = await client.execute( + `SELECT id FROM llm_models WHERE model_id = 'mock-model' LIMIT 1`, + ); + const modelId = modelRs.rows[0]?.id; + if (!modelId) { + throw new Error("Failed to insert mock LLM model"); + } + + // 设置 admin 用户的 preferredLlmModelId + await client.execute({ + sql: `INSERT INTO user_configs (user_id, key, value, value_type) + VALUES (1, 'preferredLlmModelId', ?, 'number')`, + args: [String(Number(modelId))], + }); + + log(`LLM seed complete: provider=${providerId}, model=${modelId}`); + } finally { + client.close(); + } +} + export default main; diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts index c9103d2..b006ded 100644 --- a/e2e/global-teardown.ts +++ b/e2e/global-teardown.ts @@ -17,6 +17,9 @@ async function main() { } unlinkSync(PID_FILE); } + + // mock LLM 服务器在 global-setup 进程中运行,进程退出时自动关闭 + console.log("[global-teardown] Done"); } export default main; diff --git a/e2e/mock-llm-server.ts b/e2e/mock-llm-server.ts new file mode 100644 index 0000000..ada634a --- /dev/null +++ b/e2e/mock-llm-server.ts @@ -0,0 +1,125 @@ +/** + * Mock LLM 服务器 — OpenAI 兼容格式,用于 E2E 测试。 + * + * 启动后监听 MOCK_LLM_PORT(默认 3600),提供: + * - POST /v1/chat/completions 流式聊天补全(SSE) + * + * 响应内容固定为 "你好!这是一个测试回复。" 分段流式输出, + * 保证 agent chat 的 streamText 能正常消费。 + */ +import { createServer, type Server } from "node:http"; + +const PORT = Number(process.env.MOCK_LLM_PORT) || 3600; +const REPLY = "你好!这是一个测试回复。"; + +function splitChunks(text: string): string[] { + const chunks: string[] = []; + for (const ch of text) { + chunks.push(ch); + } + return chunks; +} + +function handleChatCompletions(req: any, res: any) { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + let parsed: any = {}; + try { + parsed = JSON.parse(body); + } catch { + parsed = {}; + } + + const model = parsed.model || "mock-model"; + const chunks = splitChunks(REPLY); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + let idx = 0; + const timer = setInterval(() => { + if (idx < chunks.length) { + const delta = chunks[idx]; + const sseData = JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { content: delta }, + finish_reason: null, + }, + ], + }); + res.write(`data: ${sseData}\n\n`); + idx++; + } else { + const doneData = JSON.stringify({ + id: "chatcmpl-mock", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: "stop", + }, + ], + }); + res.write(`data: ${doneData}\n\n`); + res.write("data: [DONE]\n\n"); + res.end(); + clearInterval(timer); + } + }, 20); + }); +} + +let server: Server; + +function start(): Promise { + return new Promise((resolve) => { + server = createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Headers", "*"); + res.setHeader("Access-Control-Allow-Methods", "*"); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + const url = req.url || ""; + if (url.startsWith("/v1/chat/completions") && req.method === "POST") { + handleChatCompletions(req, res); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); + server.listen(PORT, () => { + console.log(`[mock-llm] listening on http://localhost:${PORT}`); + resolve(server); + }); + }); +} + +function stop(): Promise { + return new Promise((resolve) => { + if (server) { + server.close(() => resolve()); + } else { + resolve(); + } + }); +} + +export { start, stop }; diff --git a/e2e/specs/agent-chat.spec.ts b/e2e/specs/agent-chat.spec.ts new file mode 100644 index 0000000..31f31c6 --- /dev/null +++ b/e2e/specs/agent-chat.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from "@playwright/test"; +import { E2E_ADMIN, FIXED_CAPTCHA } from "../fixtures/test-users"; + +const MOCK_REPLY = "你好!这是一个测试回复。"; + +async function loginViaUI(page: import("@playwright/test").Page) { + await page.goto("/auth/login"); + await page.fill("#login-username", E2E_ADMIN.username); + await page.fill("#login-password", E2E_ADMIN.password); + await page.fill("#login-captcha", FIXED_CAPTCHA); + await page.click('button[type="submit"]'); + await page.waitForURL("/", { timeout: 15000 }); + await expect(page.locator(".agent-textarea")).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(1000); + const newChatBtn = page.locator(".new-chat-btn").first(); + if (await newChatBtn.isVisible()) { + await newChatBtn.click(); + await page.waitForTimeout(500); + } +} + +test.describe("Agent 对话", () => { + test("首页显示欢迎页和示例卡片", async ({ page }) => { + await loginViaUI(page); + + await expect(page.locator(".agent-welcome")).toBeVisible({ timeout: 10000 }); + await expect(page.locator(".welcome-title")).toContainText("有什么可以帮你的"); + await expect(page.locator(".example-card")).toHaveCount(4); + }); + + test("输入框和发送按钮存在", async ({ page }) => { + await loginViaUI(page); + + await expect(page.locator(".agent-textarea")).toBeVisible({ timeout: 10000 }); + await expect(page.locator(".btn-send")).toBeVisible(); + }); + + test("空消息时发送按钮禁用", async ({ page }) => { + await loginViaUI(page); + + const sendBtn = page.locator(".btn-send"); + await expect(sendBtn).toBeDisabled(); + }); + + test("输入内容后发送按钮启用", async ({ page }) => { + await loginViaUI(page); + + await page.fill(".agent-textarea", "测试消息"); + await expect(page.locator(".btn-send")).toBeEnabled(); + }); + + test("点击示例卡片直接发送消息", async ({ page }) => { + await loginViaUI(page); + + const firstExample = page.locator(".example-card").first(); + const exampleText = await firstExample.innerText(); + await firstExample.click(); + + await expect(page.locator(".agent-message.is-user .user-bubble")).toContainText(exampleText, { timeout: 10000 }); + }); + + test("发送消息后显示用户消息和助手回复", async ({ page }) => { + await loginViaUI(page); + + const testMessage = "你好,这是一条测试消息"; + await page.fill(".agent-textarea", testMessage); + await page.click(".btn-send"); + + await expect(page.locator(".agent-message.is-user .user-bubble")).toContainText(testMessage, { timeout: 10000 }); + + await expect(page.locator(".agent-message.is-assistant")).toBeVisible({ timeout: 10000 }); + + await expect( + page.locator(".agent-message.is-assistant .agent-markdown"), + ).toContainText(MOCK_REPLY, { timeout: 30000 }); + }); + + test("发送消息后显示加载动画", async ({ page }) => { + await loginViaUI(page); + + await page.fill(".agent-textarea", "测试加载状态"); + await page.click(".btn-send"); + + await expect(page.locator(".assistant-loading")).toBeVisible({ timeout: 5000 }); + }); + + test("Enter 键发送消息", async ({ page }) => { + await loginViaUI(page); + + const testMessage = "通过回车发送"; + await page.fill(".agent-textarea", testMessage); + await page.press(".agent-textarea", "Enter"); + + await expect(page.locator(".agent-message.is-user .user-bubble")).toContainText(testMessage, { timeout: 10000 }); + }); + + test("流式响应完成后消息内容完整", async ({ page }) => { + await loginViaUI(page); + + await page.fill(".agent-textarea", "请回复"); + await page.click(".btn-send"); + + await expect( + page.locator(".agent-message.is-assistant .agent-markdown"), + ).toContainText(MOCK_REPLY, { timeout: 30000 }); + + await expect(page.locator(".btn-stop")).not.toBeVisible({ timeout: 15000 }); + await expect(page.locator(".btn-send")).toBeVisible(); + }); + + test("连续对话保留历史消息", async ({ page }) => { + await loginViaUI(page); + + const firstMsg = "第一条消息"; + await page.fill(".agent-textarea", firstMsg); + await page.click(".btn-send"); + + await expect(page.locator(".agent-message.is-user .user-bubble").first()).toContainText(firstMsg, { timeout: 10000 }); + await expect( + page.locator(".agent-message.is-assistant .agent-markdown").first(), + ).toContainText(MOCK_REPLY, { timeout: 30000 }); + + const secondMsg = "第二条消息"; + await page.fill(".agent-textarea", secondMsg); + await page.click(".btn-send"); + + await expect(page.locator(".agent-message.is-user")).toHaveCount(2, { timeout: 10000 }); + await expect(page.locator(".agent-message.is-assistant")).toHaveCount(2, { timeout: 30000 }); + }); +});