Dev #21

Merged
zhangxiang merged 69 commits from dev into main 2026-08-17 20:29:08 +08:00
6 changed files with 427 additions and 19 deletions
Showing only changes of commit 3e93317988 - Show all commits
+7
View File
@@ -45,6 +45,13 @@
- 修复 use-reduced-motion.ts ESLint 错误(set-state-in-effect
- 新增 UJ-10 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)
- 测试债务追踪板同步更新,UJ-10 已解决,UJ-03/06/07 标记为环境依赖
- 2026-08: **Admin 用户旅程修复完成** — test-strategy-plan.md 版本 2.1
- 修复 `loginAdminAndSetCookie` 函数,同步设置 cookie 和 localStorage 解决认证问题
- 更新 Playwright webServer 配置为 `npm run dev` 以支持 API 路由
- UJ-03/UJ-06/UJ-07 共 9 个 admin 测试在 chromium/firefox/webkit 全部通过
- 修复 UJ-10 CSS 选择器解析问题(`text=` 正则与 CSS 混用)
- 修复 cases-filter 测试 locator 不稳定问题(`getByRole('radio')``locator('button[role="radio"]')`
- 200 个回归测试全部通过
## 项目进度
+7
View File
@@ -16,6 +16,7 @@ module.exports = {
'src/components/sections/**/*.{ts,tsx}',
'src/components/content/**/*.{ts,tsx}',
'src/components/analytics/**/*.{ts,tsx}',
'src/components/cms/**/*.{ts,tsx}',
'src/hooks/**/*.{ts,tsx}',
'src/lib/**/*.ts',
'!src/**/*.d.ts',
@@ -82,6 +83,12 @@ module.exports = {
lines: 35,
statements: 35,
},
'./src/components/cms/': {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
'./src/components/seo/': {
branches: 80,
functions: 80,
+5 -5
View File
@@ -767,7 +767,7 @@ Phase 1 (2周) Phase 2 (3周) Phase 3 (3周) Phase 4 (2周)
| 任务 | 描述 | 交付物 | 预估工时 |
|------|------|--------|----------|
| P3.1 | 开发用户旅程测试框架 | 旅程执行器 + 步骤验证器 — 已完成 ✅(UJ-01/UJ-02 | 2d |
| P3.2 | 编写 10 个用户旅程测试 | UJ-01 到 UJ-10 — 已完成 ✅ UJ-01/UJ-02/UJ-04/UJ-05/UJ-10,⏳ UJ-03/UJ-06/UJ-07 需 admin 环境 | 5d |
| P3.2 | 编写 10 个用户旅程测试 | UJ-01 到 UJ-10 — 已完成 ✅ 全部 10 个用户旅程(UJ-01~UJ-10 | 5d |
| P3.3 | 补充 E2E 测试 | 移动端 E2E、错误页面、键盘导航 — 已完成 ✅(16 个移动端测试,`e2e/mobile.spec.ts` | 3d |
| P3.4 | 补充 GA4 事件追踪测试 | 网络拦截验证事件参数 — 已完成 ✅(4 个测试用例,`e2e/ga4-event-tracking.spec.ts` | 2d |
| P3.5 | 修复 k6 压力测试脚本 | 适配混合渲染模式 — 已完成 ✅(扩展为 6 页面测试,添加分页统计) | 1d |
@@ -1185,9 +1185,9 @@ Lint + TS +Unit +集成 +E2E +变异
│ ├── [P2] UJ-05: 案例浏览者筛选旅程 ✅ 已实现(e2e/user-journey.spec.ts
│ ├── [P2] UJ-08: 移动端用户旅程 ✅ 已覆盖(e2e/mobile.spec.ts@mobile 标签,16 个用例)
│ ├── [P2] UJ-09: 错误恢复旅程 ✅ 已覆盖(e2e/p5-edge-cases.spec.ts 含 404 导航测试)
│ ├── [P2] UJ-03: 内容管理员完整旅程 ⏳ 待补充(需 admin 认证环境
│ ├── [P2] UJ-06: 多角色管理员权限旅程 ⏳ 待补充(需 admin 认证环境
│ ├── [P2] UJ-07: 媒体管理员上传旅程 ⏳ 待补充(需 admin 认证环境
│ ├── [P2] UJ-03: 内容管理员完整旅程 ✅ 已实现(e2e/user-journey.spec.ts,含登录 → 仪表盘 → 内容管理 → 编辑 → 前台可见
│ ├── [P2] UJ-06: 多角色管理员权限旅程 ✅ 已实现(e2e/user-journey.spec.ts,含角色列表 → 用户列表 → API 验证
│ ├── [P2] UJ-07: 媒体管理员上传旅程 ✅ 已实现(e2e/user-journey.spec.ts,含媒体库 → 上传 → 验证 → 清理
│ └── [P2] UJ-10: 深度搜索者旅程 ✅ 已实现(e2e/user-journey.spec.ts
├── P2 优先级 — 性能与质量缺口
@@ -1225,7 +1225,7 @@ Lint + TS +Unit +集成 +E2E +变异
└── 附带说明
├── 以上债务按优先级排列,P1 已全部解决
├── P2 剩余项为 admin 环境依赖或低优先级功能
├── P2 剩余项为低优先级功能(数据库查询性能基线)
├── P3 项为环境限制,需升级工具链或采用替代方案
└── 每次新功能开发应同步更新此追踪板
```
+8 -5
View File
@@ -6,17 +6,20 @@ test('案例页行业筛选按钮可以正确过滤列表', { tag: '@regression'
// 默认显示全部 6 个案例
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(6);
// 点击制造业筛选
await page.getByRole('radio', { name: '制造业' }).click();
// 点击"制造业"筛选
await page.locator('button[role="radio"]:has-text("制造业")').click();
await page.waitForTimeout(500);
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(1);
await expect(page.locator('text=大型制造企业 ERP 升级与数字化转型')).toBeVisible();
// 点击贸易零售筛选
await page.getByRole('radio', { name: '贸易零售' }).click();
// 点击"贸易零售"筛选
await page.locator('button[role="radio"]:has-text("贸易零售")').click();
await page.waitForTimeout(500);
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(1);
await expect(page.locator('text=连锁零售全渠道数字化升级')).toBeVisible();
// 切回全部
await page.getByRole('radio', { name: '全部行业' }).click();
await page.locator('button[role="radio"]:has-text("全部行业")').click();
await page.waitForTimeout(500);
await expect(page.locator('a[href^="/cases/"]')).toHaveCount(6);
});
+3 -1
View File
@@ -102,7 +102,9 @@ export default defineConfig({
},
],
webServer: {
command: 'npm run preview',
// 使用 dev server 以支持 API 路由(admin 用户旅程等测试需要)
// 若只运行非 API 测试(如 @journey @regression),可手动启动 npm run preview 并设置 reuseExistingServer: true
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 120000,
+397 -8
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
/**
* UJ: 用户旅程 E2E 测试
@@ -6,14 +6,12 @@ import { test, expect, type Page } from '@playwright/test';
* 覆盖范围:
* UJ-01: 潜在客户从首页到联系表单的完整旅程
* UJ-02: 行业客户浏览解决方案到产品的旅程
* UJ-03: 内容管理员完整旅程(登录 → 仪表盘 → 内容管理 → 编辑 → 前台可见)
* UJ-04: 新闻读者浏览旅程(列表 → 详情 → 返回)
* UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情)
* UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)
*
* 环境依赖(需 admin 认证环境,封版前标记为 ⏳):
* UJ-03: 内容管理员完整旅程(登录 → 创建 → 审核 → 发布 → 前台可见)
* UJ-06: 多角色管理员权限旅程(超级管理员 → 角色 → 权限 → 用户)
* UJ-07: 媒体管理员上传旅程
* UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)
*/
test.setTimeout(90000);
@@ -518,9 +516,10 @@ test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 →
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 验证页面包含发布日期
const dateIndicator = page.locator('time, [datetime], [aria-label*="date"], text=/\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}/').first();
if (await dateIndicator.count() > 0) {
// 验证页面包含发布日期(非关键断言,不阻止测试通过)
const dateIndicator = page.locator('time, [datetime], [aria-label*="date"]').first();
const hasDateElement = await dateIndicator.count() > 0;
if (hasDateElement) {
await expect(dateIndicator).toBeVisible();
}
@@ -604,4 +603,394 @@ test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 →
expect(bodyText!.length).toBeGreaterThan(0);
console.log('UJ-10 completed successfully');
});
});
// ==================== 辅助函数:Admin 登录 ====================
const ADMIN_CREDENTIALS = { username: 'admin', password: 'admin123' };
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
/**
* 通过 API 登录 admin 并设置浏览器 cookie 和 localStorage
* 使后续页面导航通过中间件认证,且前端 auth-context 能读取到用户状态。
*/
async function loginAdminAndSetCookie(page: Page, request: APIRequestContext): Promise<string> {
const loginResponse = await request.post(`${BASE_URL}/api/auth/login`, {
data: ADMIN_CREDENTIALS,
headers: { 'Content-Type': 'application/json' },
});
expect(loginResponse.ok()).toBeTruthy();
const body = await loginResponse.json();
expect(body.accessToken).toBeTruthy();
const token = body.accessToken as string;
// 设置中间件 cookie
await page.context().addCookies([
{
name: 'novalon_token',
value: token,
domain: 'localhost',
path: '/',
httpOnly: true,
sameSite: 'Lax',
},
]);
// 先导航到 admin 登录页建立同源上下文,再设置 localStorage
await page.goto(`${BASE_URL}/admin/login`, { waitUntil: 'domcontentloaded', timeout: 30000 });
// 设置前端 localStorageauth-context 读取此值判断登录状态)
await page.evaluate(
({ token, user }) => {
localStorage.setItem('novalon_admin_token', token);
localStorage.setItem('novalon_admin_user', JSON.stringify(user));
},
{ token, user: body.user }
);
return token;
}
// ==================== UJ-03: 内容管理员完整旅程 ====================
test.describe('UJ-03: 内容管理员完整旅程(登录 → 仪表盘 → 内容管理 → 编辑 → 前台可见)', () => {
test('@journey @regression @admin 管理员登录后浏览仪表盘、内容列表、编辑页面,并验证前台可见', async ({
page,
request,
}) => {
test.setTimeout(120000);
// === Step 1: 通过 API 登录并设置 cookie ===
const token = await loginAdminAndSetCookie(page, request);
expect(token).toBeTruthy();
console.log('UJ-03 admin login successful');
// === Step 2: 访问 Admin 仪表盘 ===
await page.goto('/admin', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证仪表盘加载成功(未重定向到登录页)
const currentUrl = page.url();
expect(currentUrl).not.toContain('/admin/login');
console.log('UJ-03 dashboard URL:', currentUrl);
// 验证仪表盘标题或内容存在
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
const hasDashboardContent =
bodyText!.includes('仪表盘') ||
bodyText!.includes('Dashboard') ||
bodyText!.includes('admin') ||
bodyText!.includes('管理');
expect(hasDashboardContent).toBe(true);
// === Step 3: 导航到新闻内容管理 ===
const newsContentLink = page.locator('a[href*="/admin/content/news"]').first();
if (await newsContentLink.isVisible()) {
await newsContentLink.click();
await page.waitForURL(/\/admin\/content\/news/, { timeout: 10000 });
await page.waitForTimeout(2000);
} else {
// 直接导航
await page.goto('/admin/content/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
}
// 验证内容列表页加载成功
const contentUrl = page.url();
expect(contentUrl).toContain('/admin/content/news');
console.log('UJ-03 content list URL:', contentUrl);
// 验证页面有内容
const contentBodyText = await page.locator('body').textContent();
expect(contentBodyText!.length).toBeGreaterThan(0);
// === Step 4: 通过 API 创建一篇测试新闻 ===
const suffix = `uj03-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const title = `UJ-03 测试新闻 ${suffix}`;
const slug = `uj03-test-news-${suffix}`;
// 获取模型 ID
const modelsResponse = await request.get(`${BASE_URL}/api/admin/models`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(modelsResponse.ok()).toBeTruthy();
const models = await modelsResponse.json();
const newsModel = models.find((m: { code: string }) => m.code === 'news');
expect(newsModel).toBeTruthy();
// 创建新闻
const createResponse = await request.post(`${BASE_URL}/api/admin/items`, {
data: {
modelId: newsModel.id,
modelCode: 'news',
title,
slug,
status: 'draft',
data: {
id: slug,
excerpt: `UJ-03 测试摘要 ${suffix}`,
date: new Date().toISOString().split('T')[0],
category: '公司新闻',
content: `<p>UJ-03 测试正文内容 ${suffix}</p>`,
featured: false,
},
},
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(createResponse.ok()).toBeTruthy();
const createdItem = await createResponse.json();
const itemId = createdItem.id;
console.log('UJ-03 created item:', itemId, slug);
// === Step 5: 导航到编辑页面 ===
await page.goto(`/admin/content/news/${itemId}`, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await page.waitForTimeout(2000);
// 验证编辑页面加载成功
const editUrl = page.url();
expect(editUrl).toContain(itemId);
const editBodyText = await page.locator('body').textContent();
expect(editBodyText!.length).toBeGreaterThan(0);
console.log('UJ-03 editor page loaded');
// === Step 6: 发布内容(API ===
// 提交审核
const submitResponse = await request.post(
`${BASE_URL}/api/admin/items/${itemId}/workflow`,
{
data: { action: 'submit' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
);
expect(submitResponse.ok()).toBeTruthy();
// 审核通过
const approveResponse = await request.post(
`${BASE_URL}/api/admin/items/${itemId}/workflow`,
{
data: { action: 'approve' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
);
expect(approveResponse.ok()).toBeTruthy();
// 刷新 ISR 缓存
const revalidateSecret = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
await request.post(`${BASE_URL}/api/cms/revalidate`, {
data: {
event: 'content.published',
modelCode: 'news',
slug,
secret: revalidateSecret,
},
headers: { 'Content-Type': 'application/json' },
});
console.log('UJ-03 item published and revalidated');
// === Step 7: 验证前台可见 ===
await page.goto(`/news/${slug}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证新闻详情页加载成功且包含标题
await expect
.poll(
async () => {
const h1Text =
(await page.locator('h1').first().textContent().catch(() => '')) || '';
return h1Text.includes(title);
},
{ timeout: 20000, intervals: [1000, 2000, 2000] }
)
.toBe(true);
// 验证正文内容
await expect(page.locator('body')).toContainText(`UJ-03 测试正文内容 ${suffix}`);
console.log('UJ-03 frontend verification passed');
// === Step 8: 清理测试数据 ===
await request.delete(`${BASE_URL}/api/admin/items?id=${itemId}`, {
headers: { Authorization: `Bearer ${token}` },
});
console.log('UJ-03 cleanup completed');
});
});
// ==================== UJ-06: 多角色管理员权限旅程 ====================
test.describe('UJ-06: 多角色管理员权限旅程(超级管理员 → 角色 → 权限 → 用户)', () => {
test('@journey @regression @admin 管理员访问角色管理和用户管理页面', async ({ page, request }) => {
test.setTimeout(90000);
// === Step 1: 登录并设置 cookie ===
const token = await loginAdminAndSetCookie(page, request);
expect(token).toBeTruthy();
console.log('UJ-06 admin login successful');
// === Step 2: 访问角色管理页面 ===
await page.goto('/admin/roles', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证未重定向到登录页
const rolesUrl = page.url();
expect(rolesUrl).not.toContain('/admin/login');
console.log('UJ-06 roles URL:', rolesUrl);
// 验证角色页面加载成功
const rolesBodyText = await page.locator('body').textContent();
expect(rolesBodyText!.length).toBeGreaterThan(0);
// 验证角色列表存在
const hasRoleContent =
rolesBodyText!.includes('角色') ||
rolesBodyText!.includes('super_admin') ||
rolesBodyText!.includes('权限');
expect(hasRoleContent).toBe(true);
// === Step 3: 通过 API 验证角色数据 ===
const rolesResponse = await request.get(`${BASE_URL}/api/admin/roles`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(rolesResponse.ok()).toBeTruthy();
const rolesData = await rolesResponse.json();
const roles = rolesData.roles || rolesData;
expect(Array.isArray(roles)).toBe(true);
expect(roles.length).toBeGreaterThanOrEqual(5); // super_admin, content_admin, content_editor, reviewer, readonly
console.log('UJ-06 roles found:', roles.length);
// 验证关键角色存在
const roleCodes = roles.map((r: { code: string }) => r.code);
expect(roleCodes).toContain('super_admin');
expect(roleCodes).toContain('content_admin');
expect(roleCodes).toContain('content_editor');
expect(roleCodes).toContain('reviewer');
expect(roleCodes).toContain('readonly');
// === Step 4: 访问用户管理页面 ===
await page.goto('/admin/users', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证未重定向到登录页
const usersUrl = page.url();
expect(usersUrl).not.toContain('/admin/login');
console.log('UJ-06 users URL:', usersUrl);
// 验证用户页面加载成功
const usersBodyText = await page.locator('body').textContent();
expect(usersBodyText!.length).toBeGreaterThan(0);
// 验证用户列表存在
const hasUserContent =
usersBodyText!.includes('用户') ||
usersBodyText!.includes('admin') ||
usersBodyText!.includes('角色');
expect(hasUserContent).toBe(true);
// === Step 5: 通过 API 验证用户数据 ===
const usersResponse = await request.get(`${BASE_URL}/api/admin/users`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(usersResponse.ok()).toBeTruthy();
const usersData = await usersResponse.json();
const users = usersData.users || usersData.data || usersData;
expect(Array.isArray(users)).toBe(true);
console.log('UJ-06 users found:', users.length);
});
});
// ==================== UJ-07: 媒体管理员上传旅程 ====================
test.describe('UJ-07: 媒体管理员上传旅程', () => {
test('@journey @regression @admin 管理员访问媒体管理页面并上传文件', async ({ page, request }) => {
test.setTimeout(90000);
// === Step 1: 登录并设置 cookie ===
const token = await loginAdminAndSetCookie(page, request);
expect(token).toBeTruthy();
console.log('UJ-07 admin login successful');
// === Step 2: 访问媒体管理页面 ===
await page.goto('/admin/media', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证未重定向到登录页
const mediaUrl = page.url();
expect(mediaUrl).not.toContain('/admin/login');
console.log('UJ-07 media URL:', mediaUrl);
// 验证媒体页面加载成功
const mediaBodyText = await page.locator('body').textContent();
expect(mediaBodyText!.length).toBeGreaterThan(0);
// 验证媒体管理相关元素存在
const hasMediaContent =
mediaBodyText!.includes('媒体') ||
mediaBodyText!.includes('上传') ||
mediaBodyText!.includes('素材') ||
mediaBodyText!.includes('media');
expect(hasMediaContent).toBe(true);
// === Step 3: 通过 API 获取媒体列表 ===
const mediaResponse = await request.get(`${BASE_URL}/api/admin/media`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(mediaResponse.ok()).toBeTruthy();
const mediaData = await mediaResponse.json();
const mediaItems = mediaData.items || [];
console.log('UJ-07 existing media items:', mediaItems.length);
// === Step 4: 上传测试文件(通过 API POST /api/admin/media 带 FormData ===
const testFileName = `uj07-test-${Date.now()}.txt`;
const testFileContent = 'UJ-07 test file content';
const uploadResponse = await request.post(`${BASE_URL}/api/admin/media`, {
multipart: {
file: {
name: testFileName,
mimeType: 'text/plain',
buffer: Buffer.from(testFileContent, 'utf-8'),
},
},
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(uploadResponse.ok()).toBeTruthy();
const uploadedFile = await uploadResponse.json();
// 单文件上传返回资产对象本身
expect(uploadedFile.id).toBeTruthy();
console.log('UJ-07 uploaded file:', uploadedFile.id, uploadedFile.name);
// === Step 5: 验证上传文件在媒体列表中可见 ===
const mediaAfterUploadResponse = await request.get(`${BASE_URL}/api/admin/media`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(mediaAfterUploadResponse.ok()).toBeTruthy();
const mediaAfterUpload = await mediaAfterUploadResponse.json();
const mediaAfterItems = mediaAfterUpload.items || [];
const foundUploaded = mediaAfterItems.some(
(item: { name?: string; url?: string }) =>
item.name === testFileName || (item.url && item.url.includes(testFileName))
);
expect(foundUploaded).toBe(true);
console.log('UJ-07 uploaded file verified in media list');
// === Step 6: 清理上传文件 ===
if (uploadedFile.id) {
await request.delete(`${BASE_URL}/api/admin/media?id=${uploadedFile.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
console.log('UJ-07 cleanup completed');
}
});
});