/** * 真实端到端测试 — 不隔离后端依赖 * * 与 mock 版 E2E 不同,这些测试走完完整链路: * 浏览器 → Next.js 前端 → Hono 后端 → TikHub API * * 前置条件: * 1. 后端运行在 localhost:3001 且已配置有效的 TikHub API Key * 2. 前端运行在 localhost:3000 * * 注意: * - 因依赖外部 API,测试结果可能受网络状态和 API 配额影响 * - 串行执行以避免并行请求导致的 API 限流和状态污染 * - 断言基于数据结构而非具体内容(因真实数据不可预测) */ import { test, expect } from "@playwright/test"; const API_BASE = "http://localhost:3001"; const VALID_API_KEY = process.env.TIKHUB_API_KEY || "gM8fC5kp2QikmV7IMZuR8D/TEAyDQbtE7jCE7n3bTuaTjmyeN1uNeh6AYA=="; // 全局串行执行 — 真实 E2E 共享后端状态,并行会互相干扰 test.describe.configure({ mode: "serial" }); // 每个测试给足 60 秒(外部 API 可能慢) test.setTimeout(60_000); // 确保后端已配置有效 API Key test.beforeAll(async ({ request }) => { await request.post(`${API_BASE}/api/settings`, { data: { apiKey: VALID_API_KEY }, }); }); // ─── 首页流程 ─────────────────────────────────────────────── test.describe("真实 E2E: 首页", () => { test("首页加载并渲染真实内容卡片", async ({ page }) => { await page.goto("/"); // 等待内容网格出现(真实 API 可能较慢) await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 应至少有 1 张卡片(真实数据量不可预测,但不应为空) const cards = page.getByTestId("content-card"); const count = await cards.count(); expect(count).toBeGreaterThan(0); // 每张卡片应包含标题文本(非空) const firstCardText = await cards.first().textContent(); expect(firstCardText?.trim().length).toBeGreaterThan(0); }); test("平台切换后数据真实刷新", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 记录 "全部" 模式下的卡片数 const allCount = await page.getByTestId("content-card").count(); // 切换到抖音平台 — 需要等待新数据加载 await page.getByTestId("platform-tab-douyin").click(); // 等内容网格重新出现(切换平台会触发新请求) // content-grid 在 loading 时不渲染,所以等它出现即表示新数据已到 await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); const douyinCount = await page.getByTestId("content-card").count(); expect(douyinCount).toBeGreaterThan(0); // 抖音数据应少于或等于全部 expect(douyinCount).toBeLessThanOrEqual(allCount); }); for (const platform of ["youtube", "instagram", "twitter", "bilibili", "weibo"] as const) { test(`平台 ${platform}: 切换加载内容`, async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 切换到目标平台 const tab = page.getByTestId(`platform-tab-${platform}`); await expect(tab).toBeVisible({ timeout: 10_000 }); await tab.click(); // 等待内容加载:content-grid 出现或 error/empty 出现 const grid = page.getByTestId("content-grid"); const errorState = page.getByText("加载失败"); const emptyState = page.getByTestId("empty-state"); await expect( grid.or(errorState).or(emptyState) ).toBeVisible({ timeout: 45_000 }); // 如果成功加载了内容,验证卡片结构 if (await grid.isVisible()) { const cards = page.getByTestId("content-card"); const count = await cards.count(); if (count > 0) { // 卡片应包含非空文本 const firstText = await cards.first().textContent(); expect(firstText?.trim().length).toBeGreaterThan(0); } } }); } test("排序功能改变卡片顺序", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 获取默认排序下第一张卡片标题 const firstTitleBefore = await page .getByTestId("content-card") .first() .textContent(); // 切换排序字段到 "发布时间" await page.getByTestId("sort-select").selectOption("publish_time"); // 切换后第一张卡片可能不同(不是必然,但结构应完整) const firstTitleAfter = await page .getByTestId("content-card") .first() .textContent(); // 至少卡片仍然存在 expect(firstTitleAfter?.trim().length).toBeGreaterThan(0); // 切换排序顺序(升序/降序) await page.getByTestId("sort-order").click(); const firstTitleToggled = await page .getByTestId("content-card") .first() .textContent(); expect(firstTitleToggled?.trim().length).toBeGreaterThan(0); }); }); // ─── 详情页流程 ────────────────────────────────────────────── test.describe("真实 E2E: 详情页", () => { test("从首页点击卡片进入详情页", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 点击第一张卡片 await page.getByTestId("content-card").first().click(); // URL 应跳转到 /detail/平台/ID await expect(page).toHaveURL(/\/detail\/\w+\/.+/, { timeout: 10_000 }); // 详情页应显示:标题、作者、统计数据 await expect(page.getByText("播放")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("点赞")).toBeVisible(); await expect(page.getByText("收藏")).toBeVisible(); // 查看原文按钮应存在 await expect(page.getByTestId("view-original")).toBeVisible(); await expect(page.getByTestId("view-original")).toContainText("查看原文"); }); test("详情页返回按钮回到首页", async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 进入详情 await page.getByTestId("content-card").first().click(); await expect(page).toHaveURL(/\/detail\//, { timeout: 10_000 }); await expect(page.getByTestId("detail-back")).toBeVisible({ timeout: 30_000, }); // 点击返回 await page.getByTestId("detail-back").click(); await expect(page).toHaveURL("/", { timeout: 10_000 }); }); for (const platform of ["youtube", "instagram", "twitter", "bilibili", "weibo"] as const) { test(`平台 ${platform}: 详情页可正常打开`, async ({ page }) => { // 平台详情测试需要更长时间:页面加载 + 平台切换 + 详情导航 test.setTimeout(120_000); await page.goto("/"); // 首页可能因限流而加载失败,接受 grid/error/empty 任一状态 await expect( page.getByTestId("content-grid") .or(page.getByText("加载失败")) .or(page.getByTestId("empty-state")) ).toBeVisible({ timeout: 45_000 }); // 切换到目标平台 await page.getByTestId(`platform-tab-${platform}`).click(); // 等待卡片链接更新为目标平台(或确认无内容) let hasContent = false; try { await page.waitForFunction( (p: string) => { const card = document.querySelector('[data-testid="content-card"]'); if (!card) return false; const href = card.getAttribute("href") || ""; return href.includes(`/detail/${p}/`); }, platform, { timeout: 45_000 } ); hasContent = true; } catch { // 超时说明平台无数据或加载失败 hasContent = false; } if (!hasContent) { test.skip(true, `${platform} API 未返回内容(可能是地域/配额限制),跳过详情页验证`); return; } const cards = page.getByTestId("content-card"); // 点击第一张卡片 await cards.first().click(); // URL 应跳转到对应平台的详情页 await expect(page).toHaveURL( new RegExp(`/detail/${platform}/.+`), { timeout: 10_000 } ); // 详情页应有查看原文按钮和返回按钮 await expect(page.getByTestId("view-original")).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId("detail-back")).toBeVisible(); // 返回首页 await page.getByTestId("detail-back").click(); await expect(page).toHaveURL("/", { timeout: 10_000 }); }); } }); // ─── 收藏流程 ──────────────────────────────────────────────── test.describe("真实 E2E: 收藏", () => { test("收藏真实内容并在收藏页查看", async ({ page }) => { // 先清除可能残留的 localStorage await page.goto("/"); await page.evaluate(() => localStorage.clear()); await page.reload(); await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 点击第一张卡片的收藏按钮 const firstFavBtn = page.getByTestId("favorite-btn").first(); await expect(firstFavBtn).toHaveAttribute("aria-label", "收藏"); await firstFavBtn.click(); await expect(firstFavBtn).toHaveAttribute("aria-label", "取消收藏"); // 跳转到收藏页 await page.goto("/favorites"); await expect(page.getByTestId("favorites-count")).toContainText("1 个内容"); await expect(page.getByTestId("content-card")).toHaveCount(1); }); test("取消收藏后收藏页为空", async ({ page }) => { // 清理后重新收藏 await page.goto("/"); await page.evaluate(() => localStorage.clear()); await page.reload(); await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 添加收藏 await page.getByTestId("favorite-btn").first().click(); await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute( "aria-label", "取消收藏" ); // 进入收藏页 await page.goto("/favorites"); await expect(page.getByTestId("content-card")).toHaveCount(1); // 取消收藏 await page.getByTestId("favorite-btn").first().click(); await expect(page.getByTestId("content-card")).toHaveCount(0); await expect(page.getByText("还没有收藏")).toBeVisible(); }); }); // ─── 设置流程 ──────────────────────────────────────────────── // 设置测试放在最后,因为 "保存 API Key" 会污染后端状态 test.describe("真实 E2E: 设置", () => { test("设置页加载并可配置 API Key", async ({ page }) => { await page.goto("/settings"); // 页面基本结构 await expect(page.getByRole("heading", { name: "设置" })).toBeVisible(); await expect(page.getByText("TikHub API Key")).toBeVisible(); await expect(page.getByTestId("apikey-input")).toBeVisible(); await expect(page.getByTestId("apikey-save")).toBeVisible(); }); test("空 Key 提交被前端拦截", async ({ page }) => { await page.goto("/settings"); await page.evaluate(() => localStorage.clear()); await page.reload(); await page.getByTestId("apikey-save").click(); await expect(page.getByText("请输入 API Key")).toBeVisible(); }); test("刷新间隔选择器工作正常", async ({ page }) => { await page.goto("/settings"); // 默认 30 分钟被选中 const thirtyBtn = page.getByRole("button", { name: "30 分钟" }); await expect(thirtyBtn).toHaveClass(/border-blue-500/); // 点击 10 分钟 const tenBtn = page.getByRole("button", { name: "10 分钟" }); await tenBtn.click(); await expect(tenBtn).toHaveClass(/border-blue-500/); await expect(thirtyBtn).not.toHaveClass(/border-blue-500/); }); test("保存 API Key 走真实后端并收到成功反馈", async ({ page, request }) => { await page.goto("/settings"); // 输入一个测试 Key 并保存(走真实 POST /api/settings) await page.getByTestId("apikey-input").fill("test-real-e2e-key"); await page.getByTestId("apikey-save").click(); // 验证成功 toast await expect(page.getByText("API Key 已保存")).toBeVisible({ timeout: 10_000, }); // 恢复有效的 API Key,避免污染后续测试或开发环境 await request.post(`${API_BASE}/api/settings`, { data: { apiKey: VALID_API_KEY }, }); }); }); // ─── 全链路冒烟测试 ────────────────────────────────────────── test.describe("真实 E2E: 全链路冒烟", () => { for (const platform of ["youtube", "bilibili", "weibo"] as const) { test(`平台 ${platform}: 完整用户旅程 (列表 → 详情 → 收藏)`, async ({ page }) => { // 完整旅程需要更长时间 test.setTimeout(120_000); // 清理状态 await page.goto("/"); await page.evaluate(() => localStorage.clear()); await page.reload(); await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); // 1. 切换到目标平台 await page.getByTestId(`platform-tab-${platform}`).click(); // 等待内容加载或空/错误状态 const grid = page.getByTestId("content-grid"); const errorState = page.getByText("加载失败"); const emptyState = page.getByTestId("empty-state"); await expect( grid.or(errorState).or(emptyState) ).toBeVisible({ timeout: 45_000 }); // 等待卡片链接更新为目标平台(或确认无内容) let hasContent = false; try { await page.waitForFunction( (p: string) => { const card = document.querySelector('[data-testid="content-card"]'); if (!card) return false; const href = card.getAttribute("href") || ""; return href.includes(`/detail/${p}/`); }, platform, { timeout: 15_000 } ); hasContent = true; } catch { hasContent = false; } if (!hasContent) { test.skip(true, `${platform} API 未返回内容(可能是地域/配额限制),跳过完整旅程验证`); return; } const cards = page.getByTestId("content-card"); // 2. 收藏第一张卡片 const favBtn = page.getByTestId("favorite-btn").first(); await favBtn.click(); await expect(favBtn).toHaveAttribute("aria-label", "取消收藏"); // 3. 点击卡片进入详情 await cards.first().click(); await expect(page).toHaveURL( new RegExp(`/detail/${platform}/.+`), { timeout: 10_000 } ); // 4. 详情页应有查看原文按钮和返回按钮 await expect(page.getByTestId("view-original")).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId("detail-back")).toBeVisible(); // 5. 返回首页 await page.getByTestId("detail-back").click(); await expect(page).toHaveURL("/", { timeout: 10_000 }); // 6. 进入收藏页验证 await page.goto("/favorites"); await expect(page.getByTestId("content-card")).toHaveCount(1); }); } test("完整用户旅程: 首页 → 详情 → 收藏 → 收藏页", async ({ page }) => { // 清理状态 await page.goto("/"); await page.evaluate(() => localStorage.clear()); await page.reload(); // 1. 首页加载内容 await expect(page.getByTestId("content-grid")).toBeVisible({ timeout: 45_000, }); const cardCount = await page.getByTestId("content-card").count(); expect(cardCount).toBeGreaterThan(0); // 2. 记住第一张卡片的标题用于后续验证 const firstCardTitle = await page .getByTestId("content-card") .first() .locator("h3") .textContent(); // 3. 收藏第一张卡片 await page.getByTestId("favorite-btn").first().click(); await expect(page.getByTestId("favorite-btn").first()).toHaveAttribute( "aria-label", "取消收藏" ); // 4. 点击第一张卡片进入详情 await page.getByTestId("content-card").first().click(); await expect(page).toHaveURL(/\/detail\//, { timeout: 10_000 }); // 5. 详情页应显示统计信息 await expect(page.getByText("播放")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("view-original")).toBeVisible(); // 6. 返回首页 await page.getByTestId("detail-back").click(); await expect(page).toHaveURL("/", { timeout: 10_000 }); // 7. 进入收藏页查看已收藏内容 await page.goto("/favorites"); await expect(page.getByTestId("favorites-count")).toContainText("1 个内容"); const favCard = page.getByTestId("content-card"); await expect(favCard).toHaveCount(1); // 8. 收藏页的卡片标题应与首页收藏的一致 if (firstCardTitle) { await expect(favCard.first().locator("h3")).toContainText(firstCardTitle); } }); });