test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构

- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+152 -228
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
import { test, expect, type Page, type APIRequestContext } from './fixtures';
/**
* UJ: 用户旅程 E2E 测试
@@ -16,6 +16,10 @@ import { test, expect, type Page, type APIRequestContext } from '@playwright/tes
test.setTimeout(90000);
// 本套件为桌面旅程(desktop-navigation 在 md 断点以下 display:none),
// 移动端等价旅程由 mobile-user-journeys.spec.ts 覆盖。
test.use({ viewport: { width: 1440, height: 900 } });
// ==================== 辅助函数 ====================
/**
@@ -323,145 +327,97 @@ test.describe('UJ-04: 新闻读者浏览旅程(列表 → 详情 → 返回)
const newsTitleText = await newsTitle.textContent();
expect(newsTitleText).toBeTruthy();
// 验证新闻列表存在文章条目
const newsListItems = page.locator('a[href*="/news/"]').first();
await expect(newsListItems).toBeVisible({ timeout: 5000 });
// N-27 修复:种子含 2 篇已发布新闻(company-founded、
// digital-transformation-solution),列表卡片链接 a[href*="/news/"] 恒存在。
// 原实现用 `if (articleCount > 0)` 包住整段旅程 + else 兜底 bodyText —— 新闻
// 列表消失时本例仍绿。改为硬断言,任何一步元素消失即红。
const firstArticle = page.locator('a[href*="/news/"]').first();
await expect(firstArticle).toBeVisible({ timeout: 5000 });
// 获取新闻列表中的文章链接数量
const articleLinks = page.locator('a[href*="/news/"]');
const articleCount = await articleLinks.count();
// 可能有些链接是导航/面包屑类,应该有至少1个文章链接
console.log('UJ-04 article links found:', articleCount);
// === Step 2: 点击第一篇新闻进入详情页 ===
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
firstArticle.click(),
]);
await page.waitForTimeout(1000);
if (articleCount > 0) {
// === Step 2: 点击第一篇新闻进入详情页 ===
const firstArticle = articleLinks.first();
await firstArticle.click();
// === Step 3: 验证新闻详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// 等待导航到新闻详情页
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// 验证新闻详情页有正文内容
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(50);
// === Step 3: 验证新闻详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// === Step 4: 通过面包屑返回新闻列表 ===
// news-detail-content-v3.tsx:68-71 恒渲染 breadcrumb nav 中的「新闻动态」链接
const breadcrumbNewsLink = page.locator('nav[aria-label="breadcrumb"] a[href="/news"]');
await expect(breadcrumbNewsLink).toBeVisible();
await Promise.all([
page.waitForURL(/\/news\/?$/, { timeout: 15000, waitUntil: 'commit' }),
breadcrumbNewsLink.click(),
]);
// 验证新闻详情页有发布时间或内容区域
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(50);
// === Step 4: 返回新闻列表 ===
// 通过面包屑导航返回
const breadcrumbNewsLink = page.locator('nav a[href="/news"], [aria-label*="新闻"] a, a:has-text("新闻")').first();
if (await breadcrumbNewsLink.count() > 0 && await breadcrumbNewsLink.isVisible()) {
await breadcrumbNewsLink.click();
await page.waitForURL(/\/news\/?$/, { timeout: 10000 });
await page.waitForTimeout(1000);
} else {
// 直接导航回新闻列表
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1000);
}
// 验证已返回新闻列表
await expect(page).toHaveURL(/\/news\/?$/);
} else {
// 没有文章链接时,验证页面内容存在
console.log('UJ-04: No article links found, verifying page content');
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
// 验证已返回新闻列表
await expect(page).toHaveURL(/\/news\/?$/);
});
});
// ==================== UJ-05: 案例浏览者筛选旅程 ====================
test.describe('UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情)', () => {
test('@journey @regression 客户从案例列表筛选并查看详情', async ({ page }) => {
// === Step 1: 访问案例列表 ===
test('@journey @regression 客户访问案例页,零案例种子下经共创空态转化到联系页', async ({ page }) => {
// N-27 修复:原实现用 `if (caseLinkCount > 0)` 包住详情断言 + else 兜底
// bodyText ⇒ 案例区永远空白也绿。种子刻意 0 条案例(prisma/seed.ts:628-631
// 「首批客户共创阶段,案例种子为空」),cases-content-v3.tsx:234-343 的契约是
// 「hasAnyCases=false ⇒ 渲染共创空态卡 + 行业筛选器整段缺席」。
// 「列表→筛选→详情」的真断言需要 ≥1 条已发布案例,接通前不可验证(见整改报告);
// 本例改为断言当下真实的转化旅程:案例页 → 空态出口「预约咨询」→ 联系页。
await page.goto('/cases', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
// 验证案例列表页加载成功
// 页面骨架正常:hero h1 恒定渲染(cases-content-v3.tsx:142-163)
const casesTitle = page.locator('h1').first();
await expect(casesTitle).toBeVisible({ timeout: 10000 });
const casesTitleText = await casesTitle.textContent();
expect(casesTitleText).toBeTruthy();
console.log('UJ-05 cases page title:', casesTitleText);
await expect(casesTitle).toContainText('以成果为目标');
// === Step 2: 尝试使用行业筛选(如果有筛选功能) ===
const industryFilters = page.locator(
'button:has-text("全部"), button:has-text("金融"), button:has-text("制造"), ' +
'button:has-text("医疗"), button:has-text("零售"), button:has-text("教育"), ' +
'button:has-text("科技"), [data-testid*="filter"], [data-testid*="industry"]'
);
// 零案例契约:筛选按钮组与案例卡片必须不存在(种子加入案例后此处变红,
// 提示把「列表→筛选→详情」断言接回来,而非静默跳过)
await expect(page.getByRole('group', { name: '按行业筛选' })).toHaveCount(0);
await expect(page.locator('a[href*="/cases/"]')).toHaveCount(0);
if (await industryFilters.count() > 0) {
// 点击第一个可见的筛选按钮(非"全部")
const filterButtons = page.locator(
'button:has-text("金融"), button:has-text("制造"), ' +
'button:has-text("医疗"), button:has-text("零售"), ' +
'button:has-text("科技")'
);
const filterCount = await filterButtons.count();
if (filterCount > 0) {
const firstFilter = filterButtons.first();
const filterText = await firstFilter.textContent();
console.log('UJ-05 clicking filter:', filterText);
await firstFilter.click();
await page.waitForTimeout(1500);
}
}
// 空态转化卡:预约咨询 CTA 存在且 href 契约正确
const emptyPrimaryCta = page.locator('[data-testid="cases-empty-primary-cta"]');
await expect(emptyPrimaryCta).toBeVisible();
await expect(emptyPrimaryCta).toHaveAttribute('href', '/contact');
// === Step 3: 点击案例进入详情页 ===
const caseLinks = page.locator('a[href*="/cases/"]');
const caseLinkCount = await caseLinks.count();
console.log('UJ-05 case links found:', caseLinkCount);
// === 点击转化出口,验证抵达联系页 ===
await Promise.all([
page.waitForURL((url) => new URL(url).pathname === '/contact', { timeout: 15000, waitUntil: 'commit' }),
emptyPrimaryCta.click(),
]);
await expect(page).toHaveURL((url) => url.pathname === '/contact');
if (caseLinkCount > 0) {
const firstCase = caseLinks.first();
await firstCase.click();
await page.waitForURL(/\/cases\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// === Step 4: 验证案例详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// 验证案例详情页包含内容
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 验证包含案例相关标识
const hasCaseIndicator = detailText!.includes('案例') ||
detailText!.includes('成果') ||
detailText!.includes('挑战') ||
detailText!.includes('解决方案');
expect(hasCaseIndicator).toBe(true);
} else {
// 没有案例链接时,验证页面内容存在
console.log('UJ-05: No case links found, verifying page content');
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
const contactTitle = page.locator('h1').first();
await expect(contactTitle).toBeVisible({ timeout: 10000 });
const contactText = await page.locator('main').first().textContent();
expect(contactText!.length).toBeGreaterThan(50);
});
});
// ==================== UJ-10: 深度搜索者旅程 ====================
test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)', () => {
test('@journey @regression 用户通过分类筛选浏览多篇文章并发现相关内容', async ({ page }) => {
// === Step 1: 访问新闻列表 ===
// N-27 修复:原实现用 `if (isVisible)`/`if (count > 0)` 包住 Step 2/3/4/5/6
// 几乎所有断言 ⇒ 分类按钮或文章消失时本例只剩两条哨兵断言仍绿。
// 种子契约(prisma/dev.db):恰有 2 篇已发布新闻,各属一类——
// company-founded(公司新闻)、digital-transformation-solution(研发动态);
// 分类按钮组恒渲染(news-content-v3.tsx:159-167)⇒ 全部改为硬断言。
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
@@ -471,137 +427,83 @@ test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 →
await expect(newsTitle).toBeVisible({ timeout: 10000 });
const newsTitleText = await newsTitle.textContent();
expect(newsTitleText).toBeTruthy();
console.log('UJ-10 news title:', newsTitleText);
// 验证分类筛选按钮存在
const categoryButtons = page.locator('button:has-text("公司新闻"), button:has-text("研发动态")');
const categoryCount = await categoryButtons.count();
console.log('UJ-10 category buttons found:', categoryCount);
expect(categoryCount).toBeGreaterThan(0);
// 验证分类筛选按钮存在(两个分类 chip 必须可见)
const companyNewsBtn = page.locator('button', { hasText: '公司新闻' }).first();
const devNewsBtn = page.locator('button', { hasText: '研发动态' }).first();
await expect(companyNewsBtn).toBeVisible();
await expect(devNewsBtn).toBeVisible();
// === Step 2: 点击"公司新闻"分类筛选 ===
const companyNewsBtn = page.locator('button:has-text("公司新闻")').first();
if (await companyNewsBtn.isVisible()) {
await companyNewsBtn.click();
await page.waitForTimeout(1000);
// 验证筛选后文章列表更新
const filteredArticles = page.locator('a[href*="/news/"]');
const filteredCount = await filteredArticles.count();
console.log('UJ-10 company news articles:', filteredCount);
}
// === Step 2: 点击「公司新闻」→ 列表应收窄到恰 1 篇 ===
await companyNewsBtn.click();
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
// === Step 3: 阅读第一篇新闻文章 ===
const firstArticle = page.locator('a[href*="/news/"]').first();
if (await firstArticle.count() > 0 && await firstArticle.isVisible()) {
const firstArticleHref = await firstArticle.getAttribute('href');
console.log('UJ-10 reading first article:', firstArticleHref);
await firstArticle.click();
await expect(firstArticle).toContainText('正式成立');
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
firstArticle.click(),
]);
await page.waitForTimeout(1000);
// 等待导航到新闻详情页
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// 验证新闻详情页加载成功(标题/分类徽标/正文都是恒定渲染)
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toContainText('四川睿新致远科技有限公司正式成立');
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
await expect(detailContent).toContainText('公司新闻');
// 验证新闻详情页加载成功
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
console.log('UJ-10 first article title:', detailTitleText);
// 相关内容区:两篇新闻分属不同分类,相关新闻按同分类筛选
//(news/[slug]/page.tsx:43-46)⇒ 当前种子下恒为空,section 必须缺席
await expect(page.getByRole('heading', { name: '相关新闻' })).toHaveCount(0);
// 验证详情页有正文内容
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 软验证:页面包含发布日期(非关键断言,不阻止测试通过)
const dateIndicator = page.locator('time, [datetime], [aria-label*="date"]').first();
const hasDateElement = await dateIndicator.count() > 0;
if (hasDateElement) {
await expect(dateIndicator).toBeVisible();
}
// 验证内容发现:相关新闻或推荐阅读区域
const relatedSection = page.locator(
'text=/相关新闻|推荐阅读|相关文章|RELATED|more news/i'
).first();
if (await relatedSection.count() > 0) {
await expect(relatedSection).toBeVisible();
console.log('UJ-10 related news section found');
}
}
// === Step 4: 返回新闻列表,切换分类浏览 ===
// === Step 4: 返回新闻列表,切换「研发动态」分类 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 切换到"研发动态"分类
const devNewsBtn = page.locator('button:has-text("研发动态")').first();
if (await devNewsBtn.isVisible()) {
await devNewsBtn.click();
await page.waitForTimeout(1000);
}
const devNewsBtn2 = page.locator('button', { hasText: '研发动态' }).first();
await expect(devNewsBtn2).toBeVisible();
await devNewsBtn2.click();
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
// === Step 5: 阅读第二篇新闻文章 ===
const secondArticle = page.locator('a[href*="/news/"]').first();
if (await secondArticle.count() > 0 && await secondArticle.isVisible()) {
const secondArticleHref = await secondArticle.getAttribute('href');
console.log('UJ-10 reading second article:', secondArticleHref);
await secondArticle.click();
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
secondArticle.click(),
]);
await page.waitForTimeout(1000);
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// 验证第二篇新闻详情页
const secondDetailTitle = page.locator('h1').first();
await expect(secondDetailTitle).toBeVisible({ timeout: 10000 });
const secondDetailTitleText = await secondDetailTitle.textContent();
expect(secondDetailTitleText).toBeTruthy();
expect(secondDetailTitleText!.length).toBeGreaterThan(0);
console.log('UJ-10 second article title:', secondDetailTitleText);
// 验证详情页有正文内容
const secondDetailContent = page.locator('main').first();
await expect(secondDetailContent).toBeVisible();
const secondDetailText = await secondDetailContent.textContent();
expect(secondDetailText!.length).toBeGreaterThan(100);
}
const secondDetailTitle = page.locator('h1').first();
await expect(secondDetailTitle).toContainText('数字化转型');
const secondDetailContent = page.locator('main').first();
await expect(secondDetailContent).toBeVisible();
const secondDetailText = await secondDetailContent.textContent();
expect(secondDetailText!.length).toBeGreaterThan(100);
await expect(secondDetailContent).toContainText('研发动态');
// === Step 6: 返回全部新闻列表,验证内容发现能力 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 点击"全部"恢复显示所有新闻
const allBtn = page.locator('button:has-text("全部")').first();
if (await allBtn.isVisible()) {
await allBtn.click();
await page.waitForTimeout(1000);
}
// 点击「全部」恢复显示所有新闻
const allBtn = page.locator('button', { hasText: '全部' }).first();
await expect(allBtn).toBeVisible();
await allBtn.click();
// 验证所有新闻文章显示
const allArticles = page.locator('a[href*="/news/"]');
const allCount = await allArticles.count();
console.log('UJ-10 all articles count:', allCount);
expect(allCount).toBeGreaterThanOrEqual(1);
// 全部种子新闻文章可见(恰 2 篇)
await expect(page.locator('a[href*="/news/"]')).toHaveCount(2);
// 验证搜索功能(内容发现工具)
const searchInput = page.locator('input[placeholder*="搜索"]').first();
if (await searchInput.isVisible()) {
await searchInput.fill('数字化');
await page.waitForTimeout(1000);
const searchResults = page.locator('a[href*="/news/"]');
const searchResultCount = await searchResults.count();
console.log('UJ-10 search results:', searchResultCount);
// 搜索功能正常工作,返回结果
expect(searchResultCount).toBeGreaterThanOrEqual(0);
}
// 最终验证:页面加载正常,内容可发现
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
console.log('UJ-10 completed successfully');
// 验证搜索功能(内容发现工具):输入命中词应精确收窄到 1 篇
const searchInput = page.getByLabel('搜索新闻');
await expect(searchInput).toBeVisible();
await searchInput.fill('数字化转型');
const searchResults = page.locator('a[href*="/news/"]');
await expect(searchResults).toHaveCount(1);
await expect(searchResults.first()).toContainText('数字化转型');
});
});
@@ -949,16 +851,38 @@ test.describe('UJ-07: 媒体管理员上传旅程', () => {
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';
// === Step 4: 验收 A-4 契约 —— 非白名单扩展名即使谎报 MIME 也必须拒绝 ===
// 原实现在此处上传 .txt 并断言 200:那是 A-4 修复前「任意文件可落盘」的行为,
// 断言的正是漏洞本身,故改为断言拒绝 + 白名单内真实图片成功。
const rejectedResponse = await request.post(`${BASE_URL}/api/admin/media`, {
multipart: {
file: {
name: `uj07-rejected-${Date.now()}.txt`,
mimeType: 'image/png',
buffer: Buffer.from('UJ-07 disallowed extension probe', 'utf-8'),
},
},
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(rejectedResponse.status()).toBe(400);
console.log('UJ-07 .txt upload rejected as expected');
// === Step 4b: 白名单内的真实图片上传 ===
// 96 字节 8×8 PNG,由 sharp 生成并回读校验(meta.format === 'png'),非手造字节
const pngBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQYlWM4Imf1Hx9mGBkKAGZzhsHMOkj6AAAAAElFTkSuQmCC',
'base64'
);
const testFileName = `uj07-test-${Date.now()}.png`;
const uploadResponse = await request.post(`${BASE_URL}/api/admin/media`, {
multipart: {
file: {
name: testFileName,
mimeType: 'text/plain',
buffer: Buffer.from(testFileContent, 'utf-8'),
mimeType: 'image/png',
buffer: pngBuffer,
},
},
headers: {