Browse Source
- 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 <codefree@chinatelcom.cn>feat/agent-independence
4 changed files with 396 additions and 6 deletions
@ -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<Server> { |
|||
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<void> { |
|||
return new Promise((resolve) => { |
|||
if (server) { |
|||
server.close(() => resolve()); |
|||
} else { |
|||
resolve(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
export { start, stop }; |
|||
@ -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 }); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue