17 changed files with 1367 additions and 253 deletions
File diff suppressed because it is too large
@ -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; |
|||
@ -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; |
|||
@ -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; |
|||
@ -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 }; |
|||
@ -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 }; |
|||
@ -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"] }, |
|||
}, |
|||
], |
|||
}); |
|||
@ -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 }); |
|||
}); |
|||
}); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -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 }); |
|||
}); |
|||
}); |
|||
@ -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("/"); |
|||
}); |
|||
}); |
|||
@ -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(); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue