- 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 配置收敛。
676 lines
27 KiB
TypeScript
676 lines
27 KiB
TypeScript
import { test, expect, Page } from './fixtures';
|
||
|
||
/**
|
||
* P1: 品牌视觉审计测试套件
|
||
*
|
||
* 目标:确保网站所有页面不存在 "novalon" 字样(用户可见文本),
|
||
* 所有品牌名称统一为中文 "睿新致远"
|
||
*
|
||
* 测试范围:
|
||
* - 全站文本内容扫描(大小写不敏感)
|
||
* - Logo/SVG 图形元素检查
|
||
* - 关键品牌位置验证(Header, Footer, 标题等)
|
||
* - 交互状态下的品牌一致性
|
||
* - 响应式布局下的品牌显示
|
||
*/
|
||
|
||
// 测试页面路由配置
|
||
const TEST_PAGES = [
|
||
{ path: '/', name: '首页' },
|
||
{ path: '/about', name: '关于我们' },
|
||
{ path: '/contact', name: '联系我们' },
|
||
{ path: '/products', name: '产品中心' },
|
||
{ path: '/services', name: '服务介绍' },
|
||
{ path: '/solutions', name: '解决方案' },
|
||
{ path: '/news', name: '新闻动态' },
|
||
{ path: '/team', name: '团队介绍' },
|
||
{ path: '/privacy', name: '隐私政策' },
|
||
{ path: '/terms', name: '服务条款' },
|
||
];
|
||
|
||
// 品牌名称配置
|
||
const BRAND_NAME = '睿新致远';
|
||
const FORBIDDEN_TEXT = /novalon/i; // 大小写不敏感
|
||
|
||
// 技术性上下文的正则表达式模式(这些是允许的引用)
|
||
const TECHNICAL_PATTERNS = [
|
||
/@.*\.(cn|com|net|org)/i, // 邮箱地址: contact@novalon.cn
|
||
/https?:\/\/[^\s]*novalon/i, // 完整URL
|
||
/\bnovalon\.(cn|com|net|org)\b/i, // 域名: novalon.cn
|
||
/www\.novalon\./i, // www域名
|
||
/[\w.-]*novalon[\w.-]*/i, // 其他子域名格式
|
||
];
|
||
|
||
/**
|
||
* 检查文本是否为技术性上下文(允许的引用)
|
||
* @param text 要检查的文本
|
||
* @returns 如果是技术性上下文返回 true,否则返回 false
|
||
*/
|
||
function isTechnicalContext(text: string): boolean {
|
||
return TECHNICAL_PATTERNS.some(pattern => pattern.test(text));
|
||
}
|
||
|
||
/**
|
||
* 从文本中过滤掉技术性引用后检查是否还包含禁止的文本
|
||
* @param text 原始文本
|
||
* @returns 过滤后的结果:是否包含非法的可见文本
|
||
*/
|
||
function hasVisibleForbiddenText(text: string): boolean {
|
||
// 先尝试整体匹配
|
||
if (!FORBIDDEN_TEXT.test(text)) return false;
|
||
|
||
// 如果匹配到了,检查是否全部都是技术性上下文
|
||
const lines = text.split('\n');
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
if (!trimmedLine) continue;
|
||
|
||
// 如果这行包含禁止的文本但不是技术性上下文
|
||
if (FORBIDDEN_TEXT.test(trimmedLine) && !isTechnicalContext(trimmedLine)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// 设置更长的超时时间
|
||
test.setTimeout(90000);
|
||
|
||
/**
|
||
* 安全导航到指定URL,带重试机制
|
||
* @param page Playwright Page对象
|
||
* @param path 要访问的路径
|
||
* @param maxRetries 最大重试次数
|
||
*/
|
||
async function safeGoto(page: Page, path: string, maxRetries = 2): Promise<void> {
|
||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
await page.goto(path, {
|
||
waitUntil: 'domcontentloaded',
|
||
timeout: 60000, // 增加到60秒
|
||
});
|
||
return; // 成功则返回
|
||
} catch (error) {
|
||
if (attempt === maxRetries) {
|
||
throw error; // 最后一次重试仍然失败,抛出错误
|
||
}
|
||
console.log(`Navigation to ${path} failed, retrying... (${attempt + 1}/${maxRetries})`);
|
||
await page.waitForTimeout(2000); // 等待2秒后重试
|
||
}
|
||
}
|
||
}
|
||
|
||
test.describe('P1: 品牌视觉审计 - 全站文本扫描', { tag: '@regression' }, () => {
|
||
|
||
TEST_PAGES.forEach(({ path, name }) => {
|
||
test(`${name} (${path}) - 不应包含可见的 "novalon" 字样`, async ({ page }) => {
|
||
// 使用安全导航函数(带重试机制)
|
||
await safeGoto(page, path);
|
||
|
||
// 等待客户端渲染完成
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 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; // 新增:标记是否为技术性上下文
|
||
}> = [];
|
||
|
||
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
|
||
);
|
||
|
||
// 首页 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);
|
||
});
|
||
});
|
||
|
||
test('全站 DOM 元素扫描 - 不应包含可见的 "novalon"', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 使用 JavaScript 进行深度 DOM 扫描
|
||
const scanResults = await page.evaluate(() => {
|
||
const findings: Array<{
|
||
selector: string;
|
||
text: string;
|
||
type: 'text' | 'attribute' | 'meta';
|
||
isTechnicalContext: boolean; // 标记是否为技术性上下文
|
||
}> = [];
|
||
|
||
// 技术性上下文的正则表达式(与外部定义保持一致)
|
||
const technicalPatterns = [
|
||
/@.*\.(cn|com|net|org)/i,
|
||
/https?:\/\/[^\s]*novalon/i,
|
||
/\bnovalon\.(cn|com|net|org)\b/i,
|
||
];
|
||
|
||
function isTechnical(text: string): boolean {
|
||
return technicalPatterns.some(p => p.test(text));
|
||
}
|
||
|
||
// 扫描所有文本节点
|
||
const allElements = document.querySelectorAll('*');
|
||
allElements.forEach(element => {
|
||
// 跳过 script 和 style 标签
|
||
if (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(element.tagName)) return;
|
||
|
||
// 检查文本内容
|
||
if (element.children.length === 0 && element.textContent) {
|
||
const text = element.textContent.trim();
|
||
if (text && /novalon/i.test(text)) {
|
||
const style = window.getComputedStyle(element);
|
||
if (style.display !== 'none' &&
|
||
style.visibility !== 'hidden' &&
|
||
element.getAttribute('aria-hidden') !== 'true') {
|
||
findings.push({
|
||
selector: `${element.tagName.toLowerCase()}${element.id ? '#' + element.id : ''}`,
|
||
text: text.substring(0, 50),
|
||
type: 'text',
|
||
isTechnicalContext: isTechnical(text),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查 title 和 alt 属性(这些属性通常对用户可见)
|
||
['title', 'alt', 'aria-label', 'placeholder'].forEach(attr => {
|
||
const value = element.getAttribute(attr);
|
||
if (value && /novalon/i.test(value)) {
|
||
findings.push({
|
||
selector: `[${attr}="${value}"]`,
|
||
text: value,
|
||
type: 'attribute',
|
||
isTechnicalContext: isTechnical(value),
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
// 检查 meta 标签
|
||
document.querySelectorAll('meta').forEach(meta => {
|
||
const content = meta.getAttribute('content');
|
||
const name = meta.getAttribute('name') || meta.getAttribute('property');
|
||
if (content && /novalon/i.test(content)) {
|
||
findings.push({
|
||
selector: `meta[${name || 'http-equiv'}]`,
|
||
text: content,
|
||
type: 'meta',
|
||
isTechnicalContext: isTechnical(content),
|
||
});
|
||
}
|
||
});
|
||
|
||
return findings;
|
||
});
|
||
|
||
// 过滤掉技术性上下文的发现
|
||
const nonTechnicalFindings = scanResults.filter(f => !f.isTechnicalContext);
|
||
|
||
// 首页 Hero 品牌标识中的英文品牌名 NOVALON 是有意展示的内容,予以豁免
|
||
const allowedFindings = nonTechnicalFindings.filter(f => {
|
||
if (f.type !== 'text') return false;
|
||
const text = f.text.trim().toUpperCase();
|
||
return text === 'NOVALON';
|
||
});
|
||
const reportFindings = nonTechnicalFindings.filter(f => {
|
||
if (f.type !== 'text') return true;
|
||
const text = f.text.trim().toUpperCase();
|
||
return text !== 'NOVALON';
|
||
});
|
||
|
||
// 断言:不应有非技术性且非豁免的发现
|
||
expect(
|
||
reportFindings.length,
|
||
`DOM 扫描发现 ${reportFindings.length} 处非法 "novalon" 引用(已豁免 ${allowedFindings.length} 处首页 Hero 品牌标识):\n` +
|
||
reportFindings.map(f => ` [${f.type.toUpperCase()}] ${f.selector}: "${f.text}"`).join('\n')
|
||
).toBe(0);
|
||
});
|
||
});
|
||
|
||
test.describe('P1: 品牌视觉审计 - 关键位置验证', { tag: '@regression' }, () => {
|
||
|
||
test('Header Logo 应显示 "睿新致远" 品牌', async ({ page }) => {
|
||
// 使用安全导航
|
||
await safeGoto(page, '/');
|
||
await page.waitForTimeout(1000);
|
||
|
||
// 真实契约(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 });
|
||
|
||
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 }) => {
|
||
await safeGoto(page, '/');
|
||
await page.waitForTimeout(1000);
|
||
|
||
const footer = page.locator('[data-testid="footer"], footer').first();
|
||
await expect(footer).toBeVisible({ timeout: 10000 });
|
||
|
||
// 检查 Footer 文本中包含品牌名称
|
||
const footerText = await footer.textContent();
|
||
expect(footerText).toContain(BRAND_NAME);
|
||
|
||
// 确保 Footer 中不包含禁止的文本(排除技术性引用如域名、邮箱)
|
||
expect(
|
||
hasVisibleForbiddenText(footerText || ''),
|
||
`Footer 中发现非法的可见 "novalon" 文本(排除域名/邮箱等技术性引用)`
|
||
).toBe(false);
|
||
});
|
||
|
||
test('页面标题应包含公司全称', async ({ page }) => {
|
||
await safeGoto(page, '/');
|
||
|
||
const title = await page.title();
|
||
expect(title).toContain('四川睿新致远科技有限公司');
|
||
|
||
// 标题中不应包含 novalon
|
||
expect(title.toLowerCase()).not.toContain('novalon');
|
||
});
|
||
|
||
test('Meta 描述和关键词应使用中文品牌名', async ({ page }) => {
|
||
await safeGoto(page, '/');
|
||
|
||
// 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 图形资产内的文字受控', async ({ page }) => {
|
||
await safeGoto(page, '/');
|
||
await page.waitForTimeout(1500);
|
||
|
||
// 契约:品牌文本通道禁止的是「页面文案里的 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');
|
||
}
|
||
}
|
||
|
||
// DOM 侧:Header 文本层(读屏与视觉一致的可见文本)不得出现非法 novalon
|
||
const headerText = await page.locator('header').innerText();
|
||
expect(
|
||
hasVisibleForbiddenText(headerText),
|
||
'Header 文本层包含非法 "novalon" 文本'
|
||
).toBe(false);
|
||
});
|
||
|
||
test('Logo 图片 alt 属性正确设置', async ({ page }) => {
|
||
await safeGoto(page, '/');
|
||
await page.waitForTimeout(1000);
|
||
|
||
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);
|
||
if (!(await logo.isVisible())) continue;
|
||
|
||
const alt = await logo.getAttribute('alt');
|
||
// alt="" 是装饰图的合法写法,是否缺 alt 由 axe 的 image-alt 规则负责
|
||
if (!alt) continue;
|
||
withAlt += 1;
|
||
|
||
console.log(`Header image ${i}: alt="${alt}"`);
|
||
expect(
|
||
hasVisibleForbiddenText(alt),
|
||
`Logo alt属性包含非法文本: ${alt}`
|
||
).toBe(false);
|
||
|
||
expect(alt.length, `Header 第 ${i} 张图片 alt 过短: "${alt}"`).toBeGreaterThan(2);
|
||
}
|
||
|
||
// 旧实现把上述断言包在 try/catch + console.log 中,一次异常即静默通过(§5.21)
|
||
expect(withAlt, 'header 中没有带非空 alt 的可见图片可供检查').toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
test.describe('P1: 品牌视觉审计 - 交互状态一致性', { tag: '@regression' }, () => {
|
||
|
||
test('导航链接悬停状态不显示错误品牌', async ({ page }) => {
|
||
await safeGoto(page, '/');
|
||
await page.waitForTimeout(1000);
|
||
|
||
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);
|
||
|
||
// 检查链接是否可见(跳过隐藏的链接;isVisible() 对 nth() 精确定位的单元素
|
||
// 不会抛错,N-27 修复:去掉 `.catch(() => false)` 吞错壳)
|
||
const isVisible = await link.isVisible();
|
||
if (!isVisible) {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
// 悬停状态
|
||
await link.hover({ timeout: 2000 });
|
||
await page.waitForTimeout(100);
|
||
} 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);
|
||
|
||
// 真实 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 });
|
||
|
||
await mobileMenuButton.click();
|
||
const mobileNav = page.locator('[data-testid="mobile-navigation"]');
|
||
await expect(mobileNav, '点击菜单按钮后移动端导航未展开').toBeVisible({ timeout: 5000 });
|
||
|
||
const navText = await mobileNav.textContent();
|
||
expect(
|
||
hasVisibleForbiddenText(navText || ''),
|
||
`移动端导航包含非法 "novalon" 文本`
|
||
).toBe(false);
|
||
} finally {
|
||
// 恢复默认视口
|
||
await page.setViewportSize({ width: 1280, height: 720 });
|
||
}
|
||
});
|
||
});
|
||
|
||
test.describe('P1: 品牌视觉审计 - 响应式布局验证', { tag: '@regression' }, () => {
|
||
|
||
const viewports = [
|
||
{ name: 'Desktop XL', width: 1920, height: 1080 },
|
||
{ name: 'Desktop', width: 1280, height: 800 },
|
||
{ name: 'Laptop', width: 1024, height: 768 },
|
||
{ name: 'Tablet', width: 768, height: 1024 },
|
||
{ name: 'Mobile L', width: 428, height: 926 },
|
||
{ name: 'Mobile M', width: 375, height: 667 },
|
||
{ name: 'Mobile S', width: 320, height: 568 },
|
||
];
|
||
|
||
viewports.forEach(({ name, width, height }) => {
|
||
test(`${name} (${width}x${height}) - 品牌显示一致`, async ({ page }) => {
|
||
await page.setViewportSize({ width, height });
|
||
|
||
// 对 Mobile S (320x568) 使用特殊处理(极小屏幕可能加载较慢)
|
||
const isMobileS = name === 'Mobile S';
|
||
try {
|
||
if (isMobileS) {
|
||
// Mobile S 使用更长的超时和重试
|
||
await safeGoto(page, '/', 3); // 增加重试次数到3次
|
||
} else {
|
||
await safeGoto(page, '/');
|
||
}
|
||
} catch (error) {
|
||
// 导航失败是真缺陷,不再按视口特例伪造通过(safeGoto 自带重试)
|
||
console.log(`Navigation failed for ${name}:`, error instanceof Error ? error.message : error);
|
||
throw error;
|
||
}
|
||
|
||
// 增加等待时间,确保响应式布局渲染完成
|
||
await page.waitForTimeout(isMobileS ? 3000 : 1500);
|
||
|
||
// Header 可见且包含品牌
|
||
const header = page.locator('header');
|
||
await expect(header).toBeVisible({ timeout: 10000 });
|
||
|
||
// 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();
|
||
await expect(footer).toBeVisible({ timeout: 10000 });
|
||
const footerText = await footer.textContent();
|
||
|
||
// Footer 应该包含品牌名称
|
||
expect(
|
||
footerText?.includes(BRAND_NAME),
|
||
`Footer in ${name} viewport should contain brand name`
|
||
).toBeTruthy();
|
||
});
|
||
});
|
||
});
|
||
|
||
test.describe('P1: 品牌视觉审计 - 特殊场景覆盖', { tag: '@regression' }, () => {
|
||
|
||
test('404 错误页面品牌显示正常', async ({ page }) => {
|
||
// 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" 文本`
|
||
).toBe(false);
|
||
});
|
||
|
||
test('客户评价/推荐区域品牌引用正确', async ({ page }) => {
|
||
await safeGoto(page, '/');
|
||
await page.waitForTimeout(1000);
|
||
|
||
// 查找可能的评价区域
|
||
const testimonialSelectors = [
|
||
'[data-testid*="testimonial"]',
|
||
'[data-testid*="review"]',
|
||
'[data-testid*="social-proof"]',
|
||
'.testimonial',
|
||
'.review',
|
||
'blockquote',
|
||
];
|
||
|
||
let checked = 0;
|
||
for (const selector of testimonialSelectors) {
|
||
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 }) => {
|
||
await safeGoto(page, '/');
|
||
await page.waitForTimeout(1000);
|
||
|
||
// 模拟打印媒体查询
|
||
await page.evaluate(() => {
|
||
// 触发 @media print 样式(如果有的话)
|
||
window.dispatchEvent(new Event('beforeprint'));
|
||
});
|
||
|
||
// 在打印视图中,品牌信息应该仍然存在
|
||
const bodyText = await page.evaluate(() => document.body?.innerText || '');
|
||
expect(bodyText).toContain(BRAND_NAME);
|
||
|
||
// 确保不包含非法的可见文本(排除技术性引用)
|
||
expect(
|
||
hasVisibleForbiddenText(bodyText),
|
||
`打印视图中发现非法的可见 "novalon" 文本`
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
test.describe('P1: 品牌视觉审计 - 性能与截图基线', { tag: '@regression' }, () => {
|
||
|
||
test('品牌元素加载性能', async ({ page }) => {
|
||
const startTime = Date.now();
|
||
|
||
await safeGoto(page, '/');
|
||
|
||
// 等待 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;
|
||
|
||
// Logo 应在 30 秒内加载完成(考虑到网络延迟和首次加载)
|
||
expect(loadTime).toBeLessThan(30000);
|
||
});
|
||
|
||
test('生成品牌视觉截图用于人工审核', async ({ page }, testInfo) => {
|
||
// 设置更长的超时时间用于截图
|
||
test.setTimeout(90000);
|
||
|
||
await safeGoto(page, '/');
|
||
await page.waitForTimeout(1500);
|
||
|
||
// 截图是人工审核交付物,必须真正产出非空 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' });
|
||
|
||
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' });
|
||
|
||
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' });
|
||
});
|
||
});
|