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 PREVIEW_PORT = Number(process.env.NITRO_PORT) || 3400; const MOCK_REPLY = "你好!这是一个测试回复。"; let mockLlmServer: Server | null = null; /** * 杀掉占用指定端口的进程,避免上次崩溃遗留的进程导致 EADDRINUSE。 * 使用 fuser 优雅杀掉,失败则尝试 lsof + kill。 */ function killPortOccupants(port: number) { try { execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { stdio: "pipe" }); } catch { // fuser 不可用时回退到 lsof try { const out = execSync(`lsof -t -i :${port} 2>/dev/null || true`, { stdio: "pipe", }).toString().trim(); if (out) { for (const pid of out.split("\n")) { try { process.kill(Number(pid), "SIGKILL"); } catch { /* 已退出 */ } } } } catch { /* 忽略 */ } } } function startMockLlm(): Promise { return new Promise((resolve, reject) => { 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 systemMsg = Array.isArray(parsed.messages) ? parsed.messages.find((m: any) => m.role === "system") : undefined; if (systemMsg?.content) { try { writeFileSync("/tmp/e2e-last-system-prompt.txt", String(systemMsg.content)); } catch {} } 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.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { log(`Port ${MOCK_LLM_PORT} in use, retrying cleanup...`); killPortOccupants(MOCK_LLM_PORT); setTimeout(() => { mockLlmServer?.listen(MOCK_LLM_PORT, () => { log(`[mock-llm] listening on http://localhost:${MOCK_LLM_PORT} (after retry)`); resolve(); }); }, 1000); } else { reject(err); } }); 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}`); } /** * 解析 .env.test 文件并注入到 process.env(不覆盖已有值) */ function loadEnvTest() { const envPath = path.join(ROOT, ".env.test"); if (!existsSync(envPath)) { throw new Error(`.env.test not found at ${envPath}`); } const content = readFileSync(envPath, "utf-8"); for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eqIdx = trimmed.indexOf("="); if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx).trim(); const value = trimmed.slice(eqIdx + 1).trim(); if (!(key in process.env)) { process.env[key] = value; } } log(`Loaded .env.test: E2E_TEST_MODE=${process.env.E2E_TEST_MODE}, NITRO_PORT=${process.env.NITRO_PORT}`); } async function main() { // 0. 清理上次崩溃可能遗留的进程(mock LLM + preview server) log(`Cleaning up port occupants: ${MOCK_LLM_PORT}, ${PREVIEW_PORT}...`); killPortOccupants(MOCK_LLM_PORT); killPortOccupants(PREVIEW_PORT); await new Promise((resolve) => setTimeout(resolve, 500)); // 1. 加载 .env.test loadEnvTest(); // 2. 删除旧测试数据库 if (existsSync(TEST_DB)) { log(`Removing old test DB: ${TEST_DB}`); rmSync(TEST_DB, { force: true }); } // 3. Build log("Building Nuxt app with test env..."); execSync("bun run build", { cwd: ROOT, stdio: "pipe", env: { ...process.env }, }); log("Build complete."); // 4. 在 .output 中运行迁移 log("Running migrations..."); const migrateScript = path.join(OUTPUT_DIR, "server", "migrate", "sqlite3.js"); const migrationsDir = path.join(OUTPUT_DIR, "migrations"); execSync(`node "${migrateScript}" "${migrationsDir}"`, { cwd: OUTPUT_DIR, 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: "pipe", env: { ...process.env, DATABASE_URL: `file:${TEST_DB}`, BOOTSTRAP_ADMIN_USERNAME: process.env.BOOTSTRAP_ADMIN_USERNAME || "e2e_admin", 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); // 8. 启动 preview 服务器 log("Starting preview server..."); const serverProcess = spawn("node", ["server/index.mjs"], { cwd: OUTPUT_DIR, stdio: "pipe", env: { ...process.env, DATABASE_URL: `file:${TEST_DB}`, NODE_ENV: "production", NITRO_PORT: process.env.NITRO_PORT || "3400", APP_URL: process.env.APP_URL || "http://localhost:3400", E2E_TEST_MODE: "true", E2E_FIXED_CAPTCHA: process.env.E2E_FIXED_CAPTCHA || "test1234", }, }); serverProcess.stdout?.on("data", (data) => { log(`[server] ${data.toString().trim()}`); }); serverProcess.stderr?.on("data", (data) => { log(`[server:err] ${data.toString().trim()}`); }); // 9. 等待服务器就绪 const PORT = process.env.NITRO_PORT || "3400"; const BASE_URL = `http://localhost:${PORT}`; log(`Waiting for server at ${BASE_URL}...`); const maxRetries = 60; const retryInterval = 1000; for (let i = 0; i < maxRetries; i++) { try { const res = await fetch(`${BASE_URL}/api/config/global`); if (res.ok) { log(`Server is ready! (took ${i + 1} retries)`); break; } } catch { // 服务器还没启动 } if (i === maxRetries - 1) { throw new Error(`Server failed to start within ${maxRetries} seconds`); } await new Promise((resolve) => setTimeout(resolve, retryInterval)); } // 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))], }); // 插入 default agent(seed 脚本只做 bootstrap admin,不含 agent seed) await client.execute({ sql: `INSERT INTO agents (slug, name, description, system_prompt, default_model_id, title_strategy, enable_thinking, enable_tools, max_step_count, is_default, is_callable, sort_order, enabled) VALUES ('default', '默认助手', '通用 AI 助手', 'You are a helpful AI assistant.', ?, 'llm', 0, 1, 6, 1, 0, 0, 1)`, args: [Number(modelId)], }); log(`LLM seed complete: provider=${providerId}, model=${modelId}`); } finally { client.close(); } } export default main;