Browse Source

Merge branch 'feat/e2e-testing' into feat/agent-independence

feat/agent-independence
npmrun 2 months ago
parent
commit
48e10a5205
  1. 8
      .gitignore
  2. 29
      AGENTS.md
  3. 567
      bun.lock
  4. 28
      e2e/fixtures/test-users.ts
  5. 266
      e2e/global-setup.ts
  6. 25
      e2e/global-teardown.ts
  7. 65
      e2e/helpers/auth.ts
  8. 125
      e2e/mock-llm-server.ts
  9. 54
      e2e/playwright.config.ts
  10. 130
      e2e/specs/agent-chat.spec.ts
  11. 66
      e2e/specs/auth-guard.spec.ts
  12. 102
      e2e/specs/auth-login.spec.ts
  13. 99
      e2e/specs/auth-register.spec.ts
  14. 38
      e2e/specs/smoke.spec.ts
  15. 8
      package.json
  16. 4
      packages/env/schema.ts
  17. 6
      server/service/captcha/challenge.ts

8
.gitignore

@ -23,4 +23,10 @@ logs
.env.*
!.env.example
static/upload
static/upload
# E2E test artifacts
db.test.sqlite
db.test.sqlite-*
test-results/
playwright-report/

29
AGENTS.md

@ -28,4 +28,31 @@
## 设计方案
开发页面与组件时必须参考 DESIGN.md
开发页面与组件时必须参考 DESIGN.md
## E2E 测试
### 架构
- 框架:Playwright 1.49.1 + `@nuxt/test-utils` 3.15.4
- 测试目录:`e2e/`(`specs/`、`helpers/`、`fixtures/`)
- 测试服务器:build + preview 模式,`global-setup.ts` 自动执行 build → migrate → seed → spawn server
- 数据库隔离:独立 SQLite 文件 `packages/drizzle-pkg/db.test.sqlite`,workers=1 避免并发
- 验证码注入:业务代码分支方式,`server/service/captcha/challenge.ts` 中 `E2E_TEST_MODE` 门控返回固定验证码
- 环境变量:`.env.test` 文件,由 `global-setup.ts` 和 `playwright.config.ts` 内部代码解析注入
### 运行命令
```bash
bun run test:e2e # 运行全部 E2E 测试(headless)
bun run test:e2e:headed # 有头模式运行
bun run test:e2e:ui # Playwright UI 模式
```
### 编写测试注意事项
- 用户名规则:`/^[a-zA-Z0-9_]{3,20}$/`(3-20字符,仅字母数字下划线),生成动态用户名时注意长度
- `toHaveURL` 匹配完整 URL(含 host),正则应匹配路径结尾而非开头
- 已登录状态测试:通过 UI 登录设置 cookie,不要用 `request` context(cookie 不共享到 `page`)
- 避免使用 `waitForLoadState("networkidle")`,部分页面有持续网络请求会导致超时
- SSR 中间件重定向可能不带 `?redirect=` 参数,测试应兼容此情况

567
bun.lock

File diff suppressed because it is too large

28
e2e/fixtures/test-users.ts

@ -0,0 +1,28 @@
export const E2E_ADMIN = {
username: process.env.BOOTSTRAP_ADMIN_USERNAME || "e2e_admin",
password: process.env.BOOTSTRAP_ADMIN_PASSWORD || "e2e_test_pass_123",
} as const;
export const E2E_NEW_USER = {
username: "e2e_newuser",
password: "e2e_newuser_pass_123",
} as const;
export const E2E_INVALID_USER = {
username: "e2e_nonexistent",
password: "wrong_password_123",
} as const;
export const FIXED_CAPTCHA = process.env.E2E_FIXED_CAPTCHA || "test1234";
export const PROTECTED_PATHS = [
"/settings",
"/admin",
"/admin/dashboard",
] as const;
export const PUBLIC_PATHS = [
"/",
"/auth/login",
"/auth/register",
] as const;

266
e2e/global-setup.ts

@ -0,0 +1,266 @@
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;

25
e2e/global-teardown.ts

@ -0,0 +1,25 @@
import { readFileSync, existsSync, unlinkSync } from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "..");
const PID_FILE = path.join(ROOT, ".e2e-server-pid");
async function main() {
if (existsSync(PID_FILE)) {
const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
if (pid) {
try {
process.kill(pid, "SIGTERM");
console.log(`[global-teardown] Stopped server (PID: ${pid})`);
} catch {
// 进程可能已退出
}
}
unlinkSync(PID_FILE);
}
// mock LLM 服务器在 global-setup 进程中运行,进程退出时自动关闭
console.log("[global-teardown] Done");
}
export default main;

65
e2e/helpers/auth.ts

@ -0,0 +1,65 @@
import type { APIRequestContext } from "@playwright/test";
const FIXED_CAPTCHA = process.env.E2E_FIXED_CAPTCHA || "test1234";
export interface CaptchaChallenge {
captchaId: string;
imageSvg: string;
}
/**
* 获取验证码 challenge — 调用公开 API
*/
export async function fetchCaptcha(
request: APIRequestContext,
baseURL: string,
): Promise<CaptchaChallenge> {
const res = await request.get(`${baseURL}/api/auth/captcha`);
const json = await res.json();
return {
captchaId: json.data.captchaId,
imageSvg: json.data.imageSvg,
};
}
/**
* 通过 API 登录,返回登录后的 request context(带 session cookie)
*/
export async function loginViaAPI(
request: APIRequestContext,
baseURL: string,
username: string,
password: string,
): Promise<void> {
const captcha = await fetchCaptcha(request, baseURL);
await request.post(`${baseURL}/api/auth/login`, {
data: {
username,
password,
captchaId: captcha.captchaId,
captchaAnswer: FIXED_CAPTCHA,
},
});
}
/**
* 通过 API 注册
*/
export async function registerViaAPI(
request: APIRequestContext,
baseURL: string,
username: string,
password: string,
): Promise<void> {
const captcha = await fetchCaptcha(request, baseURL);
await request.post(`${baseURL}/api/auth/register`, {
data: {
username,
password,
captchaId: captcha.captchaId,
captchaAnswer: FIXED_CAPTCHA,
},
});
}
export { FIXED_CAPTCHA };

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

54
e2e/playwright.config.ts

@ -0,0 +1,54 @@
import { defineConfig, devices } from "@playwright/test";
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "..");
// 加载 .env.test(不覆盖已有值)
const envPath = path.join(ROOT, ".env.test");
if (existsSync(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;
}
}
}
const PORT = process.env.NITRO_PORT || "3400";
const BASE_URL = process.env.APP_URL || `http://localhost:${PORT}`;
export default defineConfig({
testDir: "./specs",
fullyParallel: false,
workers: 1,
reporter: "html",
globalSetup: "./global-setup.ts",
globalTeardown: "./global-teardown.ts",
use: {
baseURL: BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
launchOptions: {
env: {
LD_LIBRARY_PATH: [
"/home/dash/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux",
process.env.LD_LIBRARY_PATH,
].filter(Boolean).join(":"),
},
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

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

66
e2e/specs/auth-guard.spec.ts

@ -0,0 +1,66 @@
import { test, expect } from "@playwright/test";
import { E2E_ADMIN, FIXED_CAPTCHA, PROTECTED_PATHS, PUBLIC_PATHS } from "../fixtures/test-users";
test.describe("路由守卫", () => {
test.describe("未登录用户", () => {
for (const path of PUBLIC_PATHS) {
test(`公开路径 ${path} 可直接访问`, async ({ page }) => {
await page.goto(path);
// toHaveURL 默认匹配完整 URL,用 path 结尾匹配
const expectedPattern = path === "/" ? /\/$/ : new RegExp(`${path.replace(/\//g, "\\/")}$`);
await expect(page).toHaveURL(expectedPattern);
});
}
for (const path of PROTECTED_PATHS) {
test(`受保护路径 ${path} 重定向到登录页`, async ({ page }) => {
await page.goto(path);
// SSR 中间件重定向到登录页(可能带 redirect 参数也可能不带)
await expect(page).toHaveURL(/\/auth\/login/);
});
}
});
test.describe("已登录用户", () => {
// 通过 UI 登录,确保 cookie 设置在 page context 中
test.beforeEach(async ({ 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 });
});
test("已登录用户访问登录页重定向到首页", async ({ page }) => {
await page.goto("/auth/login");
await page.waitForURL("/", { timeout: 15000 });
});
test("已登录用户访问注册页重定向到首页", async ({ page }) => {
await page.goto("/auth/register");
await page.waitForURL("/", { timeout: 15000 });
});
test("已登录用户可访问受保护页面", async ({ page }) => {
await page.goto("/settings");
await expect(page).toHaveURL(/\/settings/);
});
});
test.describe("API 守卫", () => {
test("未登录访问受保护 API 返回 401", async ({ request }) => {
const res = await request.get("/api/auth/session");
// session API 在白名单中,未登录时应返回 200 但 user 为 null
expect(res.ok()).toBeTruthy();
});
test("未登录访问管理 API 返回 401", async ({ request }) => {
// 尝试访问需要登录的 API
const res = await request.post("/api/cards", {
data: { title: "test" },
});
expect(res.status()).toBe(401);
});
});
});

102
e2e/specs/auth-login.spec.ts

@ -0,0 +1,102 @@
import { test, expect } from "@playwright/test";
import { E2E_ADMIN, E2E_INVALID_USER, FIXED_CAPTCHA } from "../fixtures/test-users";
test.describe("登录流程", () => {
test("管理员账号 UI 登录成功", async ({ 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).toHaveURL("/");
});
test("登录成功后 toast 提示", async ({ 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"]');
// 等待 toast 出现
await expect(page.locator(".Toastify__toast").first()).toBeVisible({ timeout: 10000 });
await expect(page.locator(".Toastify__toast").first()).toContainText("登录成功");
});
test("错误密码登录失败", async ({ page }) => {
await page.goto("/auth/login");
await page.fill("#login-username", E2E_ADMIN.username);
await page.fill("#login-password", E2E_INVALID_USER.password);
await page.fill("#login-captcha", FIXED_CAPTCHA);
await page.click('button[type="submit"]');
// 应停留在登录页
await expect(page).toHaveURL(/\/auth\/login/);
// 应显示错误 toast
await expect(page.locator(".Toastify__toast").first()).toBeVisible({ timeout: 10000 });
});
test("错误验证码登录失败", async ({ 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", "wrong_captcha");
await page.click('button[type="submit"]');
// 应停留在登录页
await expect(page).toHaveURL(/\/auth\/login/);
// 应显示错误 toast
await expect(page.locator(".Toastify__toast").first()).toBeVisible({ timeout: 10000 });
});
test("未登录访问受保护页面重定向到登录页", async ({ page }) => {
await page.goto("/settings");
// 应重定向到登录页
await expect(page).toHaveURL(/\/auth\/login/);
});
test("登录后重定向到原始页面", async ({ page }) => {
// 先访问受保护页面,触发重定向
await page.goto("/settings");
await expect(page).toHaveURL(/\/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"]');
// 登录成功后应跳转到首页(redirect 参数可能不存在)
await page.waitForURL("/", { timeout: 15000 });
});
test("已登录用户访问登录页重定向到首页", async ({ page }) => {
// 先通过 UI 登录
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 page.goto("/auth/login");
// 应重定向到首页
await page.waitForURL("/", { timeout: 15000 });
});
});

99
e2e/specs/auth-register.spec.ts

@ -0,0 +1,99 @@
import { test, expect } from "@playwright/test";
import { E2E_NEW_USER, FIXED_CAPTCHA } from "../fixtures/test-users";
// 生成短用户名(不超过20字符):e2e_ + 基于时间戳的短hash
function shortUsername(prefix: string): string {
const suffix = Date.now().toString(36).slice(-6); // 6位base36
return `${prefix}_${suffix}`; // e2e_reg_xxxxxx = 14字符
}
test.describe("注册流程", () => {
test("UI 注册成功后跳转登录页", async ({ page }) => {
const username = shortUsername("e2e_reg");
const password = E2E_NEW_USER.password;
await page.goto("/auth/register");
await page.fill("#register-username", username);
await page.fill("#register-password", password);
await page.fill("#register-confirm", password);
await page.fill("#register-captcha", FIXED_CAPTCHA);
await page.click('button[type="submit"]');
// 等待 toast 出现
await expect(page.locator(".Toastify__toast").first()).toBeVisible({ timeout: 10000 });
await expect(page.locator(".Toastify__toast").first()).toContainText("注册成功");
// 等待跳转到登录页(有 1.5s setTimeout)
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
});
test("两次密码不一致前端拦截", async ({ page }) => {
await page.goto("/auth/register");
await page.fill("#register-username", "e2e_mismatch");
await page.fill("#register-password", "password_one");
await page.fill("#register-confirm", "password_two");
await page.fill("#register-captcha", FIXED_CAPTCHA);
await page.click('button[type="submit"]');
// 应显示错误 toast
await expect(page.locator(".Toastify__toast").first()).toBeVisible({ timeout: 10000 });
await expect(page.locator(".Toastify__toast").first()).toContainText("不一致");
// 应停留在注册页
await expect(page).toHaveURL(/\/auth\/register/);
});
test("重复用户名注册失败", async ({ page }) => {
// 先注册一个用户
const username = shortUsername("e2e_dup");
const password = E2E_NEW_USER.password;
await page.goto("/auth/register");
await page.fill("#register-username", username);
await page.fill("#register-password", password);
await page.fill("#register-confirm", password);
await page.fill("#register-captcha", FIXED_CAPTCHA);
await page.click('button[type="submit"]');
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
// 再用同一用户名注册
await page.goto("/auth/register");
await page.fill("#register-username", username);
await page.fill("#register-password", password);
await page.fill("#register-confirm", password);
await page.fill("#register-captcha", FIXED_CAPTCHA);
await page.click('button[type="submit"]');
// 应显示错误 toast
await expect(page.locator(".Toastify__toast").first()).toBeVisible({ timeout: 10000 });
// 应停留在注册页
await expect(page).toHaveURL(/\/auth\/register/);
});
test("注册后可用新账号登录", async ({ page }) => {
const username = shortUsername("e2e_lr");
const password = E2E_NEW_USER.password;
// 注册
await page.goto("/auth/register");
await page.fill("#register-username", username);
await page.fill("#register-password", password);
await page.fill("#register-confirm", password);
await page.fill("#register-captcha", FIXED_CAPTCHA);
await page.click('button[type="submit"]');
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
// 登录
await page.fill("#login-username", username);
await page.fill("#login-password", password);
await page.fill("#login-captcha", FIXED_CAPTCHA);
await page.click('button[type="submit"]');
await page.waitForURL("/", { timeout: 15000 });
await expect(page).toHaveURL("/");
});
});

38
e2e/specs/smoke.spec.ts

@ -0,0 +1,38 @@
import { test, expect } from "@playwright/test";
test.describe("冒烟测试", () => {
test("首页可访问", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\//);
});
test("登录页可访问", async ({ page }) => {
await page.goto("/auth/login");
await expect(page.locator("#login-username")).toBeVisible();
await expect(page.locator("#login-password")).toBeVisible();
await expect(page.locator("#login-captcha")).toBeVisible();
});
test("注册页可访问", async ({ page }) => {
await page.goto("/auth/register");
await expect(page.locator("#register-username")).toBeVisible();
await expect(page.locator("#register-password")).toBeVisible();
await expect(page.locator("#register-confirm")).toBeVisible();
await expect(page.locator("#register-captcha")).toBeVisible();
});
test("验证码 API 返回固定验证码(E2E 模式)", async ({ request }) => {
const res = await request.get("/api/auth/captcha");
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.data.captchaId).toBeTruthy();
expect(json.data.imageSvg).toContain("e2e fixed captcha");
});
test("全局配置 API 可访问", async ({ request }) => {
const res = await request.get("/api/config/global");
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.data).toBeDefined();
});
});

8
package.json

@ -16,7 +16,10 @@
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"check": "bunx taze major -x drizzle-pkg -x logger -r -f"
"check": "bunx taze major -x drizzle-pkg -x logger -r -f",
"test:e2e": "playwright test --config e2e/playwright.config.ts",
"test:e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
"test:e2e:headed": "playwright test --config e2e/playwright.config.ts --headed"
},
"dependencies": {
"@ai-sdk/openai": "1.3.16",
@ -60,11 +63,14 @@
"devDependencies": {
"@iconify-json/ph": "^1.2.2",
"@nuxt/kit": "4.4.6",
"@nuxt/test-utils": "3.15.4",
"@playwright/test": "1.49.1",
"@tailwindcss/vite": "4.3.0",
"@types/multer": "2.1.0",
"@types/node": "25.8.0",
"@types/nodemailer": "^8.0.1",
"@types/turndown": "5.0.5",
"dotenv-cli": "7.4.4",
"drizzle-kit": "0.31.10",
"sass-embedded": "1.100.0",
"tsconfig": "workspace:*",

4
packages/env/schema.ts

@ -51,6 +51,10 @@ export const envSchema = z.object({
// ── Bootstrap (seed) ──
BOOTSTRAP_ADMIN_USERNAME: z.string().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z.string().optional(),
// ── E2E Testing ──
E2E_TEST_MODE: z.string().default("false"),
E2E_FIXED_CAPTCHA: z.string().optional(),
});
/** Inferred type of the parsed env object. */

6
server/service/captcha/challenge.ts

@ -1,10 +1,16 @@
import svgCaptcha from "svg-captcha";
import { captchaCreate } from "./store";
import { env } from "env";
/** 易混淆字符已剔除,长度 5 */
const CHAR_PRESET = "abcdefghjkmnpqrstuvwxyz23456789";
export function createCaptchaChallenge(): { captchaId: string; imageSvg: string } {
if (env.E2E_TEST_MODE === "true" && env.E2E_FIXED_CAPTCHA) {
const { captchaId } = captchaCreate(env.E2E_FIXED_CAPTCHA);
return { captchaId, imageSvg: "<svg><!-- e2e fixed captcha --></svg>" };
}
const { data: imageSvg, text } = svgCaptcha.create({
size: 5,
noise: 2,

Loading…
Cancel
Save