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:
+209
-372
@@ -1,4 +1,4 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { test, expect, Page } from './fixtures';
|
||||
|
||||
/**
|
||||
* P1: 品牌视觉审计测试套件
|
||||
@@ -111,79 +111,71 @@ test.describe('P1: 品牌视觉审计 - 全站文本扫描', { tag: '@regression
|
||||
// 等待客户端渲染完成
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 获取页面完整文本内容
|
||||
const pageText = await page.evaluate(() => {
|
||||
return document.body?.innerText || '';
|
||||
});
|
||||
// N-27 修复:原实现先跑一遍 body 正则,只有命中才进入深扫分支,
|
||||
// 未命中分支的 `expect(true).toBeTruthy()` 是零价值占位 ⇒ 断言路径被守卫。
|
||||
// 深扫本身就具备「无匹配 ⇒ 计数为 0 ⇒ 通过」的完整语义,改为无条件执行并断言。
|
||||
// 获取所有包含 novalon 的元素进行详细检查
|
||||
const elementsWithForbiddenText = await page.evaluate(() => {
|
||||
const results: Array<{
|
||||
tag: string;
|
||||
text: string;
|
||||
isVisible: boolean;
|
||||
ariaHidden: boolean;
|
||||
isTechnicalContext: boolean; // 新增:标记是否为技术性上下文
|
||||
}> = [];
|
||||
|
||||
// 检查是否包含禁止的文本
|
||||
if (FORBIDDEN_TEXT.test(pageText)) {
|
||||
// 获取所有包含 novalon 的元素进行详细检查
|
||||
const elementsWithForbiddenText = await page.evaluate(() => {
|
||||
const results: Array<{
|
||||
tag: string;
|
||||
text: string;
|
||||
isVisible: boolean;
|
||||
ariaHidden: boolean;
|
||||
isTechnicalContext: boolean; // 新增:标记是否为技术性上下文
|
||||
}> = [];
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
document.body,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
if (node.textContent && /novalon/i.test(node.textContent)) {
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
const style = window.getComputedStyle(parent);
|
||||
const text = node.textContent.trim();
|
||||
|
||||
// 检查是否为技术性上下文(邮箱、URL等)
|
||||
const isTechnicalContext =
|
||||
/@.*\.(cn|com|net|org)/i.test(text) || // 邮箱地址
|
||||
/https?:\/\/[^\s]*novalon/i.test(text) || // URL
|
||||
/\.(cn|com|net|org)/i.test(text); // 域名
|
||||
|
||||
results.push({
|
||||
tag: parent.tagName.toLowerCase(),
|
||||
text: text,
|
||||
isVisible: style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0',
|
||||
ariaHidden: parent.getAttribute('aria-hidden') === 'true',
|
||||
isTechnicalContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
// 过滤掉不可见、aria-hidden 或技术性上下文的元素
|
||||
const visibleNonTechnicalMatches = elementsWithForbiddenText.filter(
|
||||
el => el.isVisible && !el.ariaHidden && !el.isTechnicalContext
|
||||
const walker = document.createTreeWalker(
|
||||
document.body,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
// 首页 Hero 品牌标识中的英文品牌名 NOVALON 是有意展示的内容,予以豁免
|
||||
const reportMatches = path === '/'
|
||||
? visibleNonTechnicalMatches.filter(el => el.text.trim().toUpperCase() !== 'NOVALON')
|
||||
: visibleNonTechnicalMatches;
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
if (node.textContent && /novalon/i.test(node.textContent)) {
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
const style = window.getComputedStyle(parent);
|
||||
const text = node.textContent.trim();
|
||||
|
||||
// 断言:不应有可见的非技术性禁止文本
|
||||
expect(
|
||||
reportMatches.length,
|
||||
`发现 ${reportMatches.length} 处可见的非技术性 "novalon" 文本:\n` +
|
||||
reportMatches.map(el => ` [${el.tag}] "${el.text}"`).join('\n')
|
||||
).toBe(0);
|
||||
} else {
|
||||
// 没有匹配到,测试通过
|
||||
expect(true).toBeTruthy();
|
||||
}
|
||||
// 检查是否为技术性上下文(邮箱、URL等)
|
||||
const isTechnicalContext =
|
||||
/@.*\.(cn|com|net|org)/i.test(text) || // 邮箱地址
|
||||
/https?:\/\/[^\s]*novalon/i.test(text) || // URL
|
||||
/\.(cn|com|net|org)/i.test(text); // 域名
|
||||
|
||||
results.push({
|
||||
tag: parent.tagName.toLowerCase(),
|
||||
text: text,
|
||||
isVisible: style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0',
|
||||
ariaHidden: parent.getAttribute('aria-hidden') === 'true',
|
||||
isTechnicalContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
|
||||
// 过滤掉不可见、aria-hidden 或技术性上下文的元素
|
||||
const visibleNonTechnicalMatches = elementsWithForbiddenText.filter(
|
||||
el => el.isVisible && !el.ariaHidden && !el.isTechnicalContext
|
||||
);
|
||||
|
||||
// 首页 Hero 品牌标识中的英文品牌名 NOVALON 是有意展示的内容,予以豁免
|
||||
const reportMatches = path === '/'
|
||||
? visibleNonTechnicalMatches.filter(el => el.text.trim().toUpperCase() !== 'NOVALON')
|
||||
: visibleNonTechnicalMatches;
|
||||
|
||||
// 断言:不应有可见的非技术性禁止文本
|
||||
expect(
|
||||
reportMatches.length,
|
||||
`发现 ${reportMatches.length} 处可见的非技术性 "novalon" 文本:\n` +
|
||||
reportMatches.map(el => ` [${el.tag}] "${el.text}"`).join('\n')
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,80 +289,18 @@ test.describe('P1: 品牌视觉审计 - 关键位置验证', { tag: '@regression
|
||||
await safeGoto(page, '/');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 查找 Logo 元素(放宽选择器条件)
|
||||
const logoSelectors = [
|
||||
'header img',
|
||||
'header svg',
|
||||
'[data-testid="logo"] img',
|
||||
'[data-testid="logo"] svg',
|
||||
'a[href="/"] img', // Logo通常是首页链接
|
||||
'nav img', // 导航栏中的图片
|
||||
'.logo img', // class为logo的元素
|
||||
'#logo', // id为logo的元素
|
||||
];
|
||||
// 真实契约(header.tsx:96-118 渲染 `a[aria-label="返回首页"]` 内的明暗双版 Logo 图形):
|
||||
// Header 必须有一个可见 Logo 图、alt 非空且不含非法文本、src 指向品牌字标资产。
|
||||
// 旧实现用 8 个猜测式选择器 + `catch {}` 吞异常 + 「页面里只要有 img/svg 就算通过」的兜底,
|
||||
// 在现行 DOM 下等价于零断言(见 docs/lessons-learned.md §5.21)。
|
||||
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
|
||||
await expect(logo, 'Header 未渲染可见的品牌 Logo 图形').toBeVisible({ timeout: 10000 });
|
||||
|
||||
let logoFound = false;
|
||||
for (const selector of logoSelectors) {
|
||||
try {
|
||||
const logo = page.locator(selector).first();
|
||||
if (await logo.count() > 0) {
|
||||
// 检查是否可见(有些可能是隐藏的)
|
||||
const isVisible = await logo.isVisible().catch(() => false);
|
||||
if (isVisible) {
|
||||
logoFound = true;
|
||||
|
||||
// 如果是图片,检查 alt 属性或 src 属性
|
||||
const tagName = await logo.evaluate(el => el.tagName.toLowerCase());
|
||||
if (tagName === 'img') {
|
||||
const alt = await logo.getAttribute('alt') || '';
|
||||
const src = await logo.getAttribute('src') || '';
|
||||
|
||||
// 至少应该有 alt 或 src 属性包含品牌信息
|
||||
const hasBrandInfo = alt.includes(BRAND_NAME) ||
|
||||
src.toLowerCase().includes('logo') ||
|
||||
alt.length > 0;
|
||||
|
||||
console.log(`Logo found: ${selector}, alt="${alt}", src="${src}"`);
|
||||
expect(hasBrandInfo).toBeTruthy();
|
||||
} else if (tagName === 'svg') {
|
||||
// SVG Logo 应该包含中文品牌名
|
||||
const svgText = await logo.textContent() || '';
|
||||
console.log(`SVG Logo found: ${selector}, text="${svgText.substring(0, 50)}"`);
|
||||
// SVG 可能包含"睿新致远"或其他品牌标识
|
||||
expect(svgText.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略单个选择器的错误,继续尝试下一个
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到可见的Logo,检查页面中是否有任何图片或SVG(可能结构不同)
|
||||
if (!logoFound) {
|
||||
console.log('Standard logo selectors failed, trying broader search...');
|
||||
|
||||
// 更广泛的搜索:查找页面中的所有图片和SVG
|
||||
const allImages = page.locator('img');
|
||||
const allSvgs = page.locator('svg');
|
||||
|
||||
const imageCount = await allImages.count();
|
||||
const svgCount = await allSvgs.count();
|
||||
|
||||
console.log(`Page contains: ${imageCount} images, ${svgCount} SVGs`);
|
||||
|
||||
// 只要页面中有图片或SVG,就认为Logo可能存在(只是定位器需要调整)
|
||||
if (imageCount > 0 || svgCount > 0) {
|
||||
logoFound = true;
|
||||
console.log('Found images/SVGs on page - logo likely exists but needs selector adjustment');
|
||||
}
|
||||
}
|
||||
|
||||
// 最终断言:至少应该找到一些视觉元素
|
||||
expect(logoFound, '未找到 Header Logo 元素或页面中没有图片/SVG').toBeTruthy();
|
||||
const alt = await logo.getAttribute('alt') || '';
|
||||
const src = await logo.getAttribute('src') || '';
|
||||
expect(alt.length, 'Header Logo 的 alt 为空').toBeGreaterThan(2);
|
||||
expect(hasVisibleForbiddenText(alt), `Header Logo alt 包含非法文本: ${alt}`).toBe(false);
|
||||
expect(src, `Header Logo src 应指向品牌字标资产: ${src}`).toContain('logo');
|
||||
});
|
||||
|
||||
test('Footer 应包含 "睿新致远" 品牌信息', async ({ page }) => {
|
||||
@@ -404,119 +334,46 @@ test.describe('P1: 品牌视觉审计 - 关键位置验证', { tag: '@regression
|
||||
test('Meta 描述和关键词应使用中文品牌名', async ({ page }) => {
|
||||
await safeGoto(page, '/');
|
||||
|
||||
const description = await page.locator('meta[name="description"]').getAttribute('content');
|
||||
if (description) {
|
||||
expect(
|
||||
hasVisibleForbiddenText(description),
|
||||
`Meta描述包含非法 "novalon" 文本: ${description}`
|
||||
).toBe(false);
|
||||
}
|
||||
// N-27 修复:原 `if (description)` 守卫 ⇒ meta description 消失时静默通过。
|
||||
// 根 layout(src/app/layout.tsx:49)恒定声明 description ⇒ 该 meta 是硬契约。
|
||||
const descriptionMeta = page.locator('meta[name="description"]');
|
||||
await expect(descriptionMeta).toHaveCount(1);
|
||||
const description = await descriptionMeta.getAttribute('content');
|
||||
expect(description, 'meta description 应为非空').toBeTruthy();
|
||||
expect(
|
||||
hasVisibleForbiddenText(description ?? ''),
|
||||
`Meta描述包含非法 "novalon" 文本: ${description}`
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('P1: 品牌视觉审计 - SVG Logo 详细检查', { tag: '@regression' }, () => {
|
||||
|
||||
test('Logo SVG 文件不应包含 "NOVALON" 英文文本', async ({ page }) => {
|
||||
test('Logo 图形资产内的文字受控', async ({ page }) => {
|
||||
await safeGoto(page, '/');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 查找所有可能的 Logo 元素(更广泛的选择器)
|
||||
const logoSelectors = [
|
||||
'header svg',
|
||||
'[data-testid="logo"] svg',
|
||||
'img[src*="logo"]',
|
||||
'a[href="/"] svg',
|
||||
'nav svg',
|
||||
'.logo svg',
|
||||
'header img',
|
||||
'[data-testid="logo"] img',
|
||||
];
|
||||
|
||||
let logoChecked = false;
|
||||
|
||||
for (const selector of logoSelectors) {
|
||||
try {
|
||||
const logos = page.locator(selector);
|
||||
const count = await logos.count();
|
||||
|
||||
if (count > 0) {
|
||||
for (let i = 0; i < Math.min(count, 3); i++) { // 最多检查前3个
|
||||
const logo = logos.nth(i);
|
||||
const tagName = await logo.evaluate(el => el.tagName.toLowerCase());
|
||||
|
||||
// 检查是否可见
|
||||
const isVisible = await logo.isVisible().catch(() => false);
|
||||
if (!isVisible) continue;
|
||||
|
||||
if (tagName === 'svg') {
|
||||
// 检查 SVG 内部文本内容
|
||||
const svgTextContent = await logo.evaluate((element) => {
|
||||
return element.textContent || '';
|
||||
});
|
||||
|
||||
console.log(`SVG found (${selector}): "${svgTextContent.substring(0, 100)}"`);
|
||||
|
||||
// 检查是否有 NOVALON 英文文本(在 <text> 元素中)
|
||||
if (/NOVALON/i.test(svgTextContent)) {
|
||||
const hasEnglishText = await logo.evaluate((el) => {
|
||||
const textElements = el.querySelectorAll('text');
|
||||
for (const textEl of textElements) {
|
||||
if (textEl.textContent && /NOVALON/i.test(textEl.textContent)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 如果确实包含英文NOVALON,这是一个问题
|
||||
expect(hasEnglishText).toBe(false);
|
||||
}
|
||||
|
||||
// SVG 应该包含中文品牌名或至少有内容
|
||||
expect(svgTextContent.length).toBeGreaterThan(0);
|
||||
logoChecked = true;
|
||||
|
||||
} else if (tagName === 'img') {
|
||||
// 如果是 img 标签,检查 src 和 alt
|
||||
const alt = await logo.getAttribute('alt') || '';
|
||||
const src = await logo.getAttribute('src') || '';
|
||||
|
||||
console.log(`Image Logo found (${selector}): alt="${alt}", src="${src}"`);
|
||||
|
||||
// Alt 属性不应包含英文 novalon
|
||||
if (alt) {
|
||||
expect(
|
||||
hasVisibleForbiddenText(alt),
|
||||
`Logo alt属性包含非法文本: ${alt}`
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
logoChecked = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (logoChecked) break; // 找到一个有效的Logo就停止
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Error checking ${selector}:`, e instanceof Error ? e.message : e);
|
||||
continue;
|
||||
// 契约:品牌文本通道禁止的是「页面文案里的 novalon」;Logo 图形资产内的拉丁字标
|
||||
// `NOVALON` 是已批准的品牌资产(本文件 :173 对首页 Hero 的既予豁免 + 8d3bd72
|
||||
// 「unify calligraphy logo」保留了该 <text> 节点)。因此断言方式是「资产内出现的
|
||||
// <text> 只能是这个已批准字标」,而不是旧实现的「SVG 必须有 textContent」——
|
||||
// 纯 <path> 图标的 textContent 恒为空,旧断言必然抛错又被 catch 吞掉(§5.21 假绿根因)。
|
||||
for (const asset of ['/logo.svg', '/logo-white.svg']) {
|
||||
const res = await page.request.get(asset);
|
||||
expect(res.status(), `${asset} 应可取回`).toBe(200);
|
||||
const svg = await res.text();
|
||||
const textNodes = [...svg.matchAll(/<text[^>]*>([^<]*)<\/text>/g)].map((m) => m[1].trim());
|
||||
for (const value of textNodes) {
|
||||
expect(value, `${asset} 内出现未批准的 <text> 内容`).toBe('NOVALON');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到任何Logo元素,记录警告但不失败(可能是页面结构特殊)
|
||||
if (!logoChecked) {
|
||||
console.log('Warning: No visible SVG/img logo elements found with standard selectors');
|
||||
// 检查页面中是否存在任何图片或SVG(作为后备验证)
|
||||
const hasAnyVisualElements = await page.evaluate(() => {
|
||||
const images = document.querySelectorAll('img');
|
||||
const svgs = document.querySelectorAll('svg');
|
||||
return images.length > 0 || svgs.length > 0;
|
||||
});
|
||||
|
||||
console.log(`Page has visual elements: ${hasAnyVisualElements}`);
|
||||
// 只要有视觉元素存在,就认为测试通过(Logo可能使用了非标准结构)
|
||||
expect(hasAnyVisualElements).toBeTruthy();
|
||||
}
|
||||
// DOM 侧:Header 文本层(读屏与视觉一致的可见文本)不得出现非法 novalon
|
||||
const headerText = await page.locator('header').innerText();
|
||||
expect(
|
||||
hasVisibleForbiddenText(headerText),
|
||||
'Header 文本层包含非法 "novalon" 文本'
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('Logo 图片 alt 属性正确设置', async ({ page }) => {
|
||||
@@ -526,29 +383,27 @@ test.describe('P1: 品牌视觉审计 - SVG Logo 详细检查', { tag: '@regress
|
||||
const logos = page.locator('header img');
|
||||
const logoCount = await logos.count();
|
||||
|
||||
let withAlt = 0;
|
||||
for (let i = 0; i < logoCount; i++) {
|
||||
const logo = logos.nth(i);
|
||||
try {
|
||||
// 检查是否可见
|
||||
const isVisible = await logo.isVisible().catch(() => false);
|
||||
if (!isVisible) continue;
|
||||
if (!(await logo.isVisible())) continue;
|
||||
|
||||
const alt = await logo.getAttribute('alt');
|
||||
const alt = await logo.getAttribute('alt');
|
||||
// alt="" 是装饰图的合法写法,是否缺 alt 由 axe 的 image-alt 规则负责
|
||||
if (!alt) continue;
|
||||
withAlt += 1;
|
||||
|
||||
if (alt) {
|
||||
console.log(`Header image ${i}: alt="${alt}"`);
|
||||
expect(
|
||||
hasVisibleForbiddenText(alt),
|
||||
`Logo alt属性包含非法文本: ${alt}`
|
||||
).toBe(false);
|
||||
console.log(`Header image ${i}: alt="${alt}"`);
|
||||
expect(
|
||||
hasVisibleForbiddenText(alt),
|
||||
`Logo alt属性包含非法文本: ${alt}`
|
||||
).toBe(false);
|
||||
|
||||
// Alt 应该有合理的长度
|
||||
expect(alt.length).toBeGreaterThan(2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Error checking logo ${i}:`, e instanceof Error ? e.message : e);
|
||||
}
|
||||
expect(alt.length, `Header 第 ${i} 张图片 alt 过短: "${alt}"`).toBeGreaterThan(2);
|
||||
}
|
||||
|
||||
// 旧实现把上述断言包在 try/catch + console.log 中,一次异常即静默通过(§5.21)
|
||||
expect(withAlt, 'header 中没有带非空 alt 的可见图片可供检查').toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -561,13 +416,14 @@ test.describe('P1: 品牌视觉审计 - 交互状态一致性', { tag: '@regress
|
||||
const navLinks = page.locator('nav a, header a');
|
||||
const linkCount = await navLinks.count();
|
||||
|
||||
let hovered = 0;
|
||||
for (let i = 0; i < Math.min(linkCount, 10); i++) {
|
||||
const link = navLinks.nth(i);
|
||||
|
||||
// 检查链接是否可见(跳过隐藏的链接)
|
||||
const isVisible = await link.isVisible().catch(() => false);
|
||||
// 检查链接是否可见(跳过隐藏的链接;isVisible() 对 nth() 精确定位的单元素
|
||||
// 不会抛错,N-27 修复:去掉 `.catch(() => false)` 吞错壳)
|
||||
const isVisible = await link.isVisible();
|
||||
if (!isVisible) {
|
||||
console.log(`Skipping hidden link ${i}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -575,56 +431,58 @@ test.describe('P1: 品牌视觉审计 - 交互状态一致性', { tag: '@regress
|
||||
// 悬停状态
|
||||
await link.hover({ timeout: 2000 });
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// 检查 tooltip 或 title 属性
|
||||
const title = await link.getAttribute('title');
|
||||
if (title) {
|
||||
expect(
|
||||
hasVisibleForbiddenText(title),
|
||||
`导航链接标题包含非法 "novalon" 文本: ${title}`
|
||||
).toBe(false);
|
||||
}
|
||||
} catch (hoverError) {
|
||||
// 悬停本身可能因遮挡失败:这是环境噪声,但必须留下计数(旧实现连失败都不统计)
|
||||
console.log(`Hover failed for link ${i}:`, hoverError instanceof Error ? hoverError.message : hoverError);
|
||||
// 忽略悬停失败,继续检查下一个
|
||||
continue;
|
||||
}
|
||||
|
||||
hovered += 1;
|
||||
|
||||
// hover 后链接自身文本与 title 都不得引入非法品牌文本(每次都断言,不再依赖 title 是否存在)
|
||||
expect(
|
||||
hasVisibleForbiddenText((await link.innerText()).trim()),
|
||||
`导航链接 hover 后文本包含非法 "novalon"`
|
||||
).toBe(false);
|
||||
|
||||
// N-27 修复:title 为可选属性,原 `if (title)` 让断言只在属性存在时执行;
|
||||
// 改为无条件检查(缺省按空串处理,结果同样必须为 false)
|
||||
const title = await link.getAttribute('title');
|
||||
expect(
|
||||
hasVisibleForbiddenText(title ?? ''),
|
||||
`导航链接标题包含非法 "novalon" 文本: ${title ?? ''}`
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
expect(hovered, '未成功 hover 任何导航链接,本用例等于零断言').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('移动端菜单展开后品牌显示正常', async ({ page }) => {
|
||||
// 设置移动端视口
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// N-27 修复:本例显式设到 375×667(< md 断点 768px),header.tsx:192 的
|
||||
// mobile-menu-button 在此视口恒渲染 ⇒ 原 `isVisible().catch(() => false)` +
|
||||
// test.skip 兜底属于可省壳,改为硬断言(按钮消失即红,而不是静默跳过)。
|
||||
try {
|
||||
await safeGoto(page, '/');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 点击移动端菜单按钮
|
||||
const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"], button[aria-label*="menu"], .hamburger, .menu-toggle');
|
||||
if (await mobileMenuButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await mobileMenuButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
// 真实 DOM:header.tsx:189 的 data-testid="mobile-menu-button",抽屉为 :222 的 mobile-navigation。
|
||||
// 旧实现把 4 个选择器并成一个 locator,多匹配时 isVisible() 触发 strict-mode 抛错,
|
||||
// 再被外层 catch 吞成 expect(true)(§5.21)。
|
||||
const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]');
|
||||
await expect(mobileMenuButton).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 检查菜单内容
|
||||
const mobileNav = page.locator('[data-testid="mobile-navigation"], nav.mobile, .mobile-menu');
|
||||
if (await mobileNav.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
const navText = await mobileNav.textContent();
|
||||
await mobileMenuButton.click();
|
||||
const mobileNav = page.locator('[data-testid="mobile-navigation"]');
|
||||
await expect(mobileNav, '点击菜单按钮后移动端导航未展开').toBeVisible({ timeout: 5000 });
|
||||
|
||||
expect(
|
||||
hasVisibleForbiddenText(navText || ''),
|
||||
`移动端导航包含非法 "novalon" 文本`
|
||||
).toBe(false);
|
||||
}
|
||||
} else {
|
||||
console.log('Mobile menu button not found - may use different structure');
|
||||
// 即使没有找到移动端菜单按钮,也不应该失败(可能是桌面版布局)
|
||||
expect(true).toBeTruthy();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Mobile menu test error:', error instanceof Error ? error.message : error);
|
||||
// 移动端测试失败不应阻断整个测试套件
|
||||
expect(true).toBeTruthy(); // 标记为通过,但记录问题
|
||||
const navText = await mobileNav.textContent();
|
||||
expect(
|
||||
hasVisibleForbiddenText(navText || ''),
|
||||
`移动端导航包含非法 "novalon" 文本`
|
||||
).toBe(false);
|
||||
} finally {
|
||||
// 恢复默认视口
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
@@ -658,15 +516,9 @@ test.describe('P1: 品牌视觉审计 - 响应式布局验证', { tag: '@regress
|
||||
await safeGoto(page, '/');
|
||||
}
|
||||
} catch (error) {
|
||||
// 导航失败是真缺陷,不再按视口特例伪造通过(safeGoto 自带重试)
|
||||
console.log(`Navigation failed for ${name}:`, error instanceof Error ? error.message : error);
|
||||
// 如果导航失败,标记为跳过但不失败
|
||||
if (isMobileS) {
|
||||
console.log(`Skipping ${name} due to navigation timeout`);
|
||||
expect(true).toBeTruthy(); // 标记为通过
|
||||
return;
|
||||
} else {
|
||||
throw error; // 其他视口仍然抛出错误
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 增加等待时间,确保响应式布局渲染完成
|
||||
@@ -676,16 +528,10 @@ test.describe('P1: 品牌视觉审计 - 响应式布局验证', { tag: '@regress
|
||||
const header = page.locator('header');
|
||||
await expect(header).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Logo 可见(允许某些视口下 Logo 不可见的情况)
|
||||
const logo = page.locator('header img, header svg, [data-testid="logo"]').first();
|
||||
if (await logo.count() > 0) {
|
||||
try {
|
||||
await expect(logo).toBeVisible({ timeout: 2000 });
|
||||
} catch {
|
||||
// 某些小屏幕下 Logo 可能不可见,这是可接受的
|
||||
console.log(`Warning: Logo not visible in ${name} viewport`);
|
||||
}
|
||||
}
|
||||
// Logo 在每个断点都必须可见:旧实现用 `if (count > 0)` + catch 把「查不到」与「不可见」
|
||||
// 两条路都通向通过
|
||||
const logo = page.locator('header img:visible, header svg:visible').first();
|
||||
await expect(logo, `${name} 视口下 Header 没有可见 Logo`).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Footer 可见且包含品牌
|
||||
const footer = page.locator('[data-testid="footer"], footer').first();
|
||||
@@ -704,16 +550,19 @@ test.describe('P1: 品牌视觉审计 - 响应式布局验证', { tag: '@regress
|
||||
test.describe('P1: 品牌视觉审计 - 特殊场景覆盖', { tag: '@regression' }, () => {
|
||||
|
||||
test('404 错误页面品牌显示正常', async ({ page }) => {
|
||||
// 访问一个不存在的页面
|
||||
await safeGoto(page, '/nonexistent-page-12345')
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
// N-27 修复:原 `.then(() => true).catch(() => false)` 把导航失败吞成
|
||||
// 「继续在空白页上断言」⇒ 页面根本没渲染也绿。导航失败现在直接变红,
|
||||
// 并且必须先证明自定义 404 页面真的渲染了(not-found-content.tsx:14-16 的 h1「404」),
|
||||
// 再检查品牌文本。
|
||||
await safeGoto(page, '/nonexistent-page-12345');
|
||||
|
||||
// 等待自定义 404 页面渲染
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('h1').first()).toHaveText('404');
|
||||
|
||||
// 即使是 404 页面,也不应显示 novalon(排除技术性引用)
|
||||
const bodyText = await page.evaluate(() => document.body?.innerText || '');
|
||||
expect(bodyText.length, '404 页面正文不应为空').toBeGreaterThan(0);
|
||||
expect(
|
||||
hasVisibleForbiddenText(bodyText),
|
||||
`404 页面包含非法 "novalon" 文本`
|
||||
@@ -734,23 +583,27 @@ test.describe('P1: 品牌视觉审计 - 特殊场景覆盖', { tag: '@regression
|
||||
'blockquote',
|
||||
];
|
||||
|
||||
let checked = 0;
|
||||
for (const selector of testimonialSelectors) {
|
||||
try {
|
||||
const element = page.locator(selector).first();
|
||||
if (await element.count() > 0 && await element.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
const text = await element.textContent();
|
||||
if (text && text.trim().length > 0) {
|
||||
expect(
|
||||
hasVisibleForbiddenText(text),
|
||||
`客户评价区域包含非法 "novalon" 文本`
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Error checking ${selector}:`, e instanceof Error ? e.message : e);
|
||||
continue;
|
||||
}
|
||||
const element = page.locator(selector).first();
|
||||
if (await element.count() === 0) continue;
|
||||
// N-27 修复:去掉 `isVisible({timeout}).catch(() => false)` 吞错壳;
|
||||
// 不可见选择器本就可跳过,真正的地板是下方无条件的 checked > 0 断言。
|
||||
if (!(await element.isVisible())) continue;
|
||||
|
||||
const text = (await element.textContent()) || '';
|
||||
if (!text.trim()) continue;
|
||||
checked += 1;
|
||||
|
||||
expect(
|
||||
hasVisibleForbiddenText(text),
|
||||
`客户评价区域包含非法 "novalon" 文本`
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// 首页确实渲染 blockquote(social-proof-section.tsx:102),因此零命中就是契约被破坏,
|
||||
// 不能像旧实现那样走「catch + console.log」静默通过(§5.21)
|
||||
expect(checked, '未找到任何可见的客户评价区域,本用例等于零断言').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('打印视图品牌信息保留', async ({ page }) => {
|
||||
@@ -782,14 +635,9 @@ test.describe('P1: 品牌视觉审计 - 性能与截图基线', { tag: '@regress
|
||||
|
||||
await safeGoto(page, '/');
|
||||
|
||||
// 等待 Logo 加载完成(使用更宽松的超时)
|
||||
const logo = page.locator('header img, header svg, [data-testid="logo"]').first();
|
||||
try {
|
||||
await logo.waitFor({ state: 'visible', timeout: 10000 });
|
||||
} catch {
|
||||
console.log('Logo not visible within 10s - may use different structure');
|
||||
// Logo 可能使用了非标准结构,继续测试
|
||||
}
|
||||
// 等待 Logo 可见;10s 内不可见即失败(旧实现 catch 后继续跑,等于不做检查)
|
||||
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
|
||||
await logo.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
@@ -797,42 +645,31 @@ test.describe('P1: 品牌视觉审计 - 性能与截图基线', { tag: '@regress
|
||||
expect(loadTime).toBeLessThan(30000);
|
||||
});
|
||||
|
||||
test('生成品牌视觉截图用于人工审核', async ({ page }) => {
|
||||
test('生成品牌视觉截图用于人工审核', async ({ page }, testInfo) => {
|
||||
// 设置更长的超时时间用于截图
|
||||
test.setTimeout(90000);
|
||||
|
||||
await safeGoto(page, '/');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
try {
|
||||
// 截取 Header 区域
|
||||
const header = page.locator('header');
|
||||
await expect(header).toBeVisible({ timeout: 10000 });
|
||||
await header.screenshot({
|
||||
path: 'test-results/p1-brand-audit-header.png',
|
||||
fullPage: false,
|
||||
}).catch((e) => console.log('Header screenshot failed:', e instanceof Error ? e.message : e));
|
||||
// 截图是人工审核交付物,必须真正产出非空 PNG 并挂到报告附件上。
|
||||
// 旧实现三处 `.catch(e => console.log(...))` + 结尾 expect(true),一张都没截到也算通过(§5.21)。
|
||||
const header = page.locator('header');
|
||||
await expect(header, 'Header 区域不可见').toBeVisible({ timeout: 10000 });
|
||||
const headerPng = await header.screenshot({ fullPage: false });
|
||||
expect(headerPng.byteLength, 'Header 截图为空').toBeGreaterThan(1000);
|
||||
await testInfo.attach('p1-brand-audit-header', { body: headerPng, contentType: 'image/png' });
|
||||
|
||||
// 截取 Footer 区域
|
||||
const footer = page.locator('[data-testid="footer"], footer').first();
|
||||
await expect(footer).toBeVisible({ timeout: 10000 });
|
||||
await footer.screenshot({
|
||||
path: 'test-results/p1-brand-audit-footer.png',
|
||||
fullPage: false,
|
||||
}).catch((e) => console.log('Footer screenshot failed:', e instanceof Error ? e.message : e));
|
||||
const footer = page.locator('[data-testid="footer"], footer').first();
|
||||
await expect(footer, 'Footer 区域不可见').toBeVisible({ timeout: 10000 });
|
||||
const footerPng = await footer.screenshot({ fullPage: false });
|
||||
expect(footerPng.byteLength, 'Footer 截图为空').toBeGreaterThan(1000);
|
||||
await testInfo.attach('p1-brand-audit-footer', { body: footerPng, contentType: 'image/png' });
|
||||
|
||||
// 截取 Logo 特写
|
||||
const logo = page.locator('header img, header svg, [data-testid="logo"]').first();
|
||||
if (await logo.count() > 0 && await logo.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await logo.screenshot({
|
||||
path: 'test-results/p1-brand-audit-logo.png',
|
||||
}).catch((e) => console.log('Logo screenshot failed:', e instanceof Error ? e.message : e));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Screenshot generation encountered an error:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
|
||||
// 测试通过(截图用于人工审核,即使部分失败也不应阻断)
|
||||
expect(true).toBeTruthy();
|
||||
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
|
||||
await expect(logo, 'Logo 图形不可见').toBeVisible({ timeout: 10000 });
|
||||
const logoPng = await logo.screenshot();
|
||||
expect(logoPng.byteLength, 'Logo 截图为空').toBeGreaterThan(200);
|
||||
await testInfo.attach('p1-brand-audit-logo', { body: logoPng, contentType: 'image/png' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user