项目从单体结构重构为 pnpm monorepo (shared/backend/frontend), 新增 YouTube、Instagram、Twitter/X、哔哩哔哩、微博 5 个平台适配器, 包含完整的单元测试和 E2E 测试覆盖。 - 完成 T-031~T-044: 5 个适配器实现、注册、配置和测试 - 重构前后端分离: Hono 后端 + Next.js 前端 - 151 个单元测试 + 21 个 Mock E2E + 25 个真实 E2E - 适配器基于真实 TikHub API 响应结构实现 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
/**
|
||
* 真实端到端测试 — 不隔离后端依赖
|
||
*
|
||
* 与 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);
|
||
}
|
||
});
|
||
});
|