Browse Source

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 <codefree@chinatelcom.cn>
feat/agent-independence
npmrun 2 months ago
parent
commit
d3e9e481fe
  1. 144
      e2e/global-setup.ts
  2. 3
      e2e/global-teardown.ts
  3. 125
      e2e/mock-llm-server.ts
  4. 130
      e2e/specs/agent-chat.spec.ts

144
e2e/global-setup.ts

@ -1,10 +1,79 @@
import { execSync, spawn } from "node:child_process"; import { execSync, spawn } from "node:child_process";
import { existsSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { existsSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import path from "node:path"; 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 ROOT = path.resolve(import.meta.dirname, "..");
const OUTPUT_DIR = path.join(ROOT, ".output"); const OUTPUT_DIR = path.join(ROOT, ".output");
const TEST_DB = path.join(ROOT, "packages", "drizzle-pkg", "db.test.sqlite"); 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<void> {
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) { function log(msg: string) {
console.log(`[global-setup] ${msg}`); console.log(`[global-setup] ${msg}`);
@ -47,9 +116,10 @@ async function main() {
log("Building Nuxt app with test env..."); log("Building Nuxt app with test env...");
execSync("bun run build", { execSync("bun run build", {
cwd: ROOT, cwd: ROOT,
stdio: "inherit", stdio: "pipe",
env: { ...process.env }, env: { ...process.env },
}); });
log("Build complete.");
// 4. 在 .output 中运行迁移 // 4. 在 .output 中运行迁移
log("Running migrations..."); log("Running migrations...");
@ -57,19 +127,20 @@ async function main() {
const migrationsDir = path.join(OUTPUT_DIR, "migrations"); const migrationsDir = path.join(OUTPUT_DIR, "migrations");
execSync(`node "${migrateScript}" "${migrationsDir}"`, { execSync(`node "${migrateScript}" "${migrationsDir}"`, {
cwd: OUTPUT_DIR, cwd: OUTPUT_DIR,
stdio: "inherit", stdio: "pipe",
env: { env: {
...process.env, ...process.env,
DATABASE_URL: `file:${TEST_DB}`, DATABASE_URL: `file:${TEST_DB}`,
}, },
}); });
log("Migrations complete.");
// 5. 在 .output 中运行 seed(创建管理员账号) // 5. 在 .output 中运行 seed(创建管理员账号)
log("Running seed..."); log("Running seed...");
const seedScript = path.join(OUTPUT_DIR, "server", "seed", "sqlite3.js"); const seedScript = path.join(OUTPUT_DIR, "server", "seed", "sqlite3.js");
execSync(`node "${seedScript}"`, { execSync(`node "${seedScript}"`, {
cwd: OUTPUT_DIR, cwd: OUTPUT_DIR,
stdio: "inherit", stdio: "pipe",
env: { env: {
...process.env, ...process.env,
DATABASE_URL: `file:${TEST_DB}`, DATABASE_URL: `file:${TEST_DB}`,
@ -77,8 +148,16 @@ async function main() {
BOOTSTRAP_ADMIN_PASSWORD: process.env.BOOTSTRAP_ADMIN_PASSWORD || "e2e_test_pass_123", 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..."); log("Starting preview server...");
const serverProcess = spawn("node", ["server/index.mjs"], { const serverProcess = spawn("node", ["server/index.mjs"], {
cwd: OUTPUT_DIR, cwd: OUTPUT_DIR,
@ -101,7 +180,7 @@ async function main() {
log(`[server:err] ${data.toString().trim()}`); log(`[server:err] ${data.toString().trim()}`);
}); });
// 7. 等待服务器就绪 // 9. 等待服务器就绪
const PORT = process.env.NITRO_PORT || "3400"; const PORT = process.env.NITRO_PORT || "3400";
const BASE_URL = `http://localhost:${PORT}`; const BASE_URL = `http://localhost:${PORT}`;
log(`Waiting for server at ${BASE_URL}...`); log(`Waiting for server at ${BASE_URL}...`);
@ -124,11 +203,64 @@ async function main() {
await new Promise((resolve) => setTimeout(resolve, retryInterval)); await new Promise((resolve) => setTimeout(resolve, retryInterval));
} }
// 8. 保存 server PID 供 teardown 使用 // 10. 保存 server PID 供 teardown 使用
const pidFile = path.join(ROOT, ".e2e-server-pid"); const pidFile = path.join(ROOT, ".e2e-server-pid");
writeFileSync(pidFile, String(serverProcess.pid)); writeFileSync(pidFile, String(serverProcess.pid));
log("Global setup complete!"); 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; export default main;

3
e2e/global-teardown.ts

@ -17,6 +17,9 @@ async function main() {
} }
unlinkSync(PID_FILE); unlinkSync(PID_FILE);
} }
// mock LLM 服务器在 global-setup 进程中运行,进程退出时自动关闭
console.log("[global-teardown] Done");
} }
export default main; export default main;

125
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<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 };

130
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 });
});
});
Loading…
Cancel
Save