muse_creative_hotspots/e2e-real/real-e2e.spec.ts
wxs 6cc703ada2 feat: monorepo 重构 + 新增 5 个平台适配器
项目从单体结构重构为 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>
2026-03-03 15:43:25 +08:00

497 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 真实端到端测试 — 不隔离后端依赖
*
* 与 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);
}
});
});