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.
266 lines
8.7 KiB
266 lines
8.7 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 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) {
|
|
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() {
|
|
// 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))],
|
|
});
|
|
|
|
log(`LLM seed complete: provider=${providerId}, model=${modelId}`);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
export default main;
|
|
|