You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

325 lines
11 KiB

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<void> {
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) {
(globalThis as any).__lastMockSystemPrompt = systemMsg.content;
}
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;