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
+224 -229
View File
@@ -1,265 +1,260 @@
import { test, expect } from '@playwright/test';
import { test, expect, type Page } from './fixtures';
/**
* GA4 事件追踪验证测试套件
* GA4 事件追踪验证
*
* 通过 Playwright 网络拦截与页面评估,验证 Google Analytics 4 事件是否正确触发。
* 由于测试环境中的 NEXT_PUBLIC_GA_MEASUREMENT_ID 为空,应用的 analytics 模块
* 不会实际调用 gtag。本测试通过 addInitScript 注入 mock gtag 来记录调用,
* 并模拟用户交互后应触发的 analytics 调用来验证事件参数的正确性。
* 验收 A-6 重写:原实现先注入 mock window.gtag,再由测试自己调用 gtag 并断言
* 刚写入的 __gtagCalls —— 断言的是 fixture,不是应用。且 .env.production 配置了
* 真实 MEASUREMENT_ID 后,应用注入的 gtag.js 会覆盖 mock,导致全部用例失败。
*
* 现在只拦截 Google 的远端脚本(CI 不出网),gtag 函数与 dataLayer 队列由应用
* 自己的内联片段创建(src/components/analytics/GoogleAnalytics.tsx),
* 因此 window.dataLayer 中的每一条调用都必然由应用发出。
*
* 测试范围:
* 1. TC-GA4-001: 页面浏览追踪 - 首页加载时触发 gtag config
* 2. TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件
* 3. TC-GA4-003: CTA 按钮点击触发 button_click 事件
* 4. TC-GA4-004: 页面浏览追踪 - 产品页加载时触发 gtag config
* 1. TC-GA4-001: 首屏内联片段发出 js / consent default / 唯一一次 config
* 2. TC-GA4-002: 联系表单提交触发 form_submit + 单次 conversion
* 3. TC-GA4-003: 点击「接受所有」触发 button_click + consent update
* 4. TC-GA4-004: 客户端路由跳转触发带 page_path 的 config
*/
test.setTimeout(60000);
type GtagCall = unknown[];
type GtagParams = Record<string, unknown>;
/** 内联片段把参数按 arguments 对象压入队列,这里统一展开成数组便于断言。 */
async function gtagCalls(page: Page): Promise<GtagCall[]> {
return page.evaluate(() => {
const queue = (window as unknown as { dataLayer?: ArrayLike<unknown>[] }).dataLayer;
if (!queue) {return [];}
return Array.from(queue).map((entry) => Array.from(entry as ArrayLike<unknown>));
});
}
function callsWith(calls: GtagCall[], ...prefix: unknown[]): GtagCall[] {
return calls.filter((call) => prefix.every((value, index) => call[index] === value));
}
function params(call: GtagCall | undefined): GtagParams {
return (call?.[2] ?? {}) as GtagParams;
}
/** 拦截 Google 远端脚本,返回应用请求携带的 MEASUREMENT_ID;未配置 GA 时返回 null。 */
async function gotoWithGtag(page: Page, path: string): Promise<string | null> {
await page.route(/https?:\/\/www\.googletagmanager\.com\//, (route) =>
route.fulfill({
status: 200,
contentType: 'application/javascript',
body: '/* GA4 E2E stub: 保留应用自身的 dataLayer 队列 */',
})
);
const gtagRequest = page
.waitForRequest(/www\.googletagmanager\.com\/gtag\/js/, { timeout: 15000 })
.catch(() => null);
await page.goto(path, { timeout: 30000, waitUntil: 'domcontentloaded' });
const request = await gtagRequest;
if (!request) {return null;}
return new URL(request.url()).searchParams.get('id');
}
/** 预置同意偏好:抑制横幅遮挡,同时让应用发出 consent update。 */
async function seedConsent(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem(
'novalon-cookie-preferences',
JSON.stringify({
necessary: true,
analytics: true,
marketing: false,
functionality: true,
timestamp: Date.now(),
})
);
});
}
/**
* e2e/playwright.config.ts:29 的 storageState 全站预置了同意偏好(analytics:false),
* 因此横幅默认不出现;需要真实横幅的用例必须先清除该键。
*/
async function clearSeededConsent(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.removeItem('novalon-cookie-preferences');
});
}
async function expectGtagReady(page: Page): Promise<void> {
await expect
.poll(async () => (await gtagCalls(page)).length, { timeout: 15000 })
.toBeGreaterThan(0);
}
/** 等应用内联片段把 js / consent default / config 三条都写入队列后再断言,避免读到半成品。 */
async function expectSnippetCalls(page: Page): Promise<GtagCall[]> {
await expect
.poll(async () => {
const calls = await gtagCalls(page);
const present = [
callsWith(calls, 'js').length,
callsWith(calls, 'consent', 'default').length,
callsWith(calls, 'config').length,
];
return present.filter((count) => count > 0).length;
}, { timeout: 15000 })
.toBe(3);
return gtagCalls(page);
}
/** GoogleAnalytics 只在客户端路由变化时补发带 page_path 的 config。 */
async function routedPageViews(page: Page, measurementId: string | null): Promise<GtagCall[]> {
return callsWith(await gtagCalls(page), 'config', measurementId ?? '');
}
test.describe('GA4 事件追踪验证', { tag: ['@analytics', '@regression', '@critical'] }, () => {
test.describe.configure({ mode: 'parallel' });
// 在每个测试前设置 gtag mock
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
(window as any).gtag = (...args: any[]) => {
if (!(window as any).__gtagCalls) {
(window as any).__gtagCalls = [];
}
(window as any).__gtagCalls.push(args);
};
window.dataLayer = [];
});
});
test('TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config', async ({ page }) => {
await seedConsent(page);
const measurementId = await gotoWithGtag(page, '/');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
/**
* Helper: 关闭 Cookie 同意横幅(如果存在)
*/
async function closeCookieConsent(page: any) {
const acceptAllBtn = page.locator('button', { hasText: '接受所有' });
if (await acceptAllBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await acceptAllBtn.click();
await page.waitForTimeout(500);
}
}
const calls = await expectSnippetCalls(page);
test('TC-GA4-001: 页面浏览追踪 - 首页加载时触发 gtag config', async ({ page }) => {
// 导航到首页
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
expect(callsWith(calls, 'js')).toHaveLength(1);
// 关闭 Cookie 同意横幅
await closeCookieConsent(page);
const consentDefault = callsWith(calls, 'consent', 'default');
expect(consentDefault).toHaveLength(1);
expect(params(consentDefault[0]).analytics_storage).toBe('granted');
expect(params(consentDefault[0]).ad_storage).toBe('denied');
// 模拟首页加载时应触发的 gtag config 调用
await page.evaluate(() => {
(window as any).gtag('config', 'G-TEST123', {
page_path: '/',
page_title: document.title,
page_location: window.location.origin + '/',
});
});
// 验证 gtag config 调用
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
const configCalls = gtagCalls.filter((call: any[]) => call[0] === 'config');
expect(configCalls.length).toBeGreaterThan(0);
expect(configCalls[0][1]).toBe('G-TEST123');
expect(configCalls[0][2]).toBeDefined();
expect(configCalls[0][2].page_path).toBe('/');
expect(configCalls[0][2].page_title).toBeTruthy();
const configs = callsWith(calls, 'config');
expect(configs).toHaveLength(1);
expect(configs[0]?.[1]).toBe(measurementId);
expect(params(configs[0]).anonymize_ip).toBe(true);
});
test('TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件', async ({ page }) => {
// 拦截 /api/contact 请求,模拟成功响应
await page.route('**/api/contact', async (route) => {
await route.fulfill({
await seedConsent(page);
await page.route('**/api/contact', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: 'true' }),
});
});
})
);
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const measurementId = await gotoWithGtag(page, '/contact');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
await expectGtagReady(page);
// 关闭 Cookie 同意横幅
await closeCookieConsent(page);
await page.locator('[data-testid="name-input"]').first().fill('测试用户');
await page.locator('[data-testid="phone-input"]').first().fill('13800138000');
await page.locator('[data-testid="email-input"]').first().fill('test@example.com');
await page.locator('[data-testid="subject-input"]').first().fill('咨询产品方案');
await page
.locator('[data-testid="message-input"]')
.first()
.fill('您好,我想了解贵公司的ERP产品和CRM产品,请提供详细方案和报价。');
// 填写表单
const nameInput = page.locator('[data-testid="name-input"]').first();
await nameInput.fill('测试用户');
const phoneInput = page.locator('[data-testid="phone-input"]').first();
await phoneInput.fill('13800138000');
const emailInput = page.locator('[data-testid="email-input"]').first();
await emailInput.fill('test@example.com');
const subjectInput = page.locator('[data-testid="subject-input"]').first();
await subjectInput.fill('咨询产品方案');
const messageInput = page.locator('[data-testid="message-input"]').first();
await messageInput.fill('您好,我想了解贵公司的ERP产品和CRM产品,请提供详细方案和报价。');
// 提交表单
const submitButton = page.locator('[data-testid="submit-button"]').first();
await expect(submitButton).toBeEnabled({ timeout: 5000 });
await expect(submitButton).toBeEnabled({ timeout: 10000 });
await submitButton.click();
// 等待表单提交成功
await page.waitForTimeout(2000);
await expect
.poll(
async () => {
const calls = await gtagCalls(page);
return (
callsWith(calls, 'event', 'form_submit').length +
callsWith(calls, 'event', 'conversion').length
);
},
{ timeout: 15000 }
)
.toBeGreaterThanOrEqual(2);
// 验证表单提交成功(显示成功消息)
const successMessage = page.locator('text=提交成功').first();
const isSuccessVisible = await successMessage.isVisible({ timeout: 5000 }).catch(() => false);
if (isSuccessVisible) {
await expect(successMessage).toBeVisible();
const calls = await gtagCalls(page);
const formSubmit = callsWith(calls, 'event', 'form_submit');
expect(formSubmit).toHaveLength(1);
expect(params(formSubmit[0]).event_category).toBe('contact');
expect(params(formSubmit[0]).event_label).toBe('咨询产品方案');
expect(params(formSubmit[0]).event_value).toBe(1);
expect(params(formSubmit[0]).send_to).toBe(measurementId);
// contact-content-v3 只调用 trackContactForm(内部已发 conversion),
// 计数为 1 是「一次提交 = 一次转化」的回归护栏。
const conversions = callsWith(calls, 'event', 'conversion');
expect(conversions).toHaveLength(1);
expect(params(conversions[0]).conversion_label).toBe('contact_form_submit');
expect(params(conversions[0]).value).toBe(1);
expect(params(conversions[0]).currency).toBe('CNY');
expect(typeof params(conversions[0]).transaction_id).toBe('string');
});
test('TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件', async ({ page }) => {
await clearSeededConsent(page);
const measurementId = await gotoWithGtag(page, '/');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
const acceptAll = page.locator('button', { hasText: '接受所有' });
await expect(acceptAll).toBeVisible({ timeout: 15000 });
await acceptAll.click();
await expect
.poll(
async () => callsWith(await gtagCalls(page), 'event', 'button_click').length,
{ timeout: 10000 }
)
.toBe(2);
const calls = await gtagCalls(page);
const buttonClick = callsWith(calls, 'event', 'button_click');
// handleAcceptAll 先复用 handleSavePreferences(save_cookie_preferences),再补发自身事件。
expect(buttonClick.map((call) => params(call).event_label)).toEqual([
'save_cookie_preferences',
'accept_all_cookies',
]);
for (const call of buttonClick) {
expect(params(call).event_category).toBe('engagement');
expect(params(call).send_to).toBe(measurementId);
}
// 模拟表单提交成功后应触发的 analytics 事件
// trackContactForm 内部会调用:
// - trackEvent('form_submit', 'contact', '咨询产品方案', 1)
// - trackConversion('contact_form_submit', 1)
await page.evaluate(() => {
// 模拟 form_submit 事件
(window as any).gtag('event', 'form_submit', {
event_category: 'contact',
event_label: '咨询产品方案',
event_value: 1,
send_to: 'G-TEST123',
});
const consentUpdate = callsWith(calls, 'consent', 'update');
expect(consentUpdate.length).toBeGreaterThan(0);
expect(params(consentUpdate[consentUpdate.length - 1]).analytics_storage).toBe('granted');
// 模拟 conversion 事件
(window as any).gtag('event', 'conversion', {
send_to: 'G-TEST123',
transaction_id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
value: 1,
currency: 'CNY',
conversion_label: 'contact_form_submit',
});
});
await expect(acceptAll).toBeHidden({ timeout: 10000 });
});
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
test('TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config', async ({ page }) => {
await seedConsent(page);
const measurementId = await gotoWithGtag(page, '/');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
await expectGtagReady(page);
// 验证 form_submit 事件
const formSubmitCalls = gtagCalls.filter(
(call: any[]) => call[0] === 'event' && call[1] === 'form_submit'
const privacyLink = page.locator('footer a[href="/privacy"]').first();
await expect(privacyLink).toBeVisible({ timeout: 15000 });
await privacyLink.click();
await expect(page).toHaveURL(/\/privacy$/);
await expect
.poll(async () => {
const views = await routedPageViews(page, measurementId);
return views.filter((call) => params(call).page_path === '/privacy').length;
}, { timeout: 15000 })
.toBe(1);
const routeConfig = (await routedPageViews(page, measurementId)).filter(
(call) => params(call).page_path === '/privacy'
);
expect(formSubmitCalls.length).toBeGreaterThan(0);
expect(formSubmitCalls[0][2].event_category).toBe('contact');
expect(formSubmitCalls[0][2].event_label).toBeTruthy();
// 验证 conversion 事件
const conversionCalls = gtagCalls.filter(
(call: any[]) => call[0] === 'event' && call[1] === 'conversion'
);
expect(conversionCalls.length).toBeGreaterThan(0);
expect(conversionCalls[0][2].conversion_label).toBe('contact_form_submit');
expect(conversionCalls[0][2].value).toBe(1);
expect(conversionCalls[0][2].currency).toBe('CNY');
// A-17:SPA pageview 必须在下一帧读取标题,否则 page_title 会是空串
// (由 GoogleAnalytics.test.tsx「read document.title after metadata paints」钉住)。
expect(String(params(routeConfig[0]).page_title)).toContain('隐私政策');
expect(params(routeConfig[0]).page_location).toContain('/privacy');
});
test('TC-GA4-003: CTA 按钮点击触发 button_click 事件', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 关闭 Cookie 同意横幅
await closeCookieConsent(page);
// 滚动到页面底部以触发 CTA 区域
await page.evaluate(() => {
const ctaSection = document.querySelector('#cta');
if (ctaSection) {
ctaSection.scrollIntoView({ behavior: 'instant', block: 'center' });
}
});
await page.waitForTimeout(500);
// 查找 CTA 按钮("预约免费咨询")
const ctaButton = page.locator('#cta a', { hasText: '预约免费咨询' }).first();
const isCtaVisible = await ctaButton.isVisible({ timeout: 5000 }).catch(() => false);
if (isCtaVisible) {
// 模拟点击 CTA 按钮后应触发的 button_click 事件
// 使用 button_click 事件格式: trackButtonClick 调用 trackEvent('button_click', 'engagement', buttonName)
await page.evaluate(() => {
(window as any).gtag('event', 'button_click', {
event_category: 'engagement',
event_label: '预约免费咨询',
send_to: 'G-TEST123',
});
});
// 验证 button_click 事件
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
const buttonClickCalls = gtagCalls.filter(
(call: any[]) => call[0] === 'event' && call[1] === 'button_click'
);
expect(buttonClickCalls.length).toBeGreaterThan(0);
expect(buttonClickCalls[0][2].event_category).toBe('engagement');
expect(buttonClickCalls[0][2].event_label).toBe('预约免费咨询');
} else {
// CTA 按钮不可见时,验证其他导航按钮的点击事件
const navButton = page.locator('nav a, header a', { hasText: '联系我们' }).first();
const isNavVisible = await navButton.isVisible({ timeout: 3000 }).catch(() => false);
if (isNavVisible) {
await page.evaluate(() => {
(window as any).gtag('event', 'button_click', {
event_category: 'navigation',
event_label: '联系我们',
send_to: 'G-TEST123',
});
});
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
const buttonClickCalls = gtagCalls.filter(
(call: any[]) => call[0] === 'event' && call[1] === 'button_click'
);
expect(buttonClickCalls.length).toBeGreaterThan(0);
expect(buttonClickCalls[0][2].event_category).toBe('navigation');
expect(buttonClickCalls[0][2].event_label).toBe('联系我们');
} else {
// 降级验证:至少 verify gtag 函数可用并可记录事件
const gtagFunctionExists = await page.evaluate(() => typeof (window as any).gtag === 'function');
expect(gtagFunctionExists).toBe(true);
}
}
});
test('TC-GA4-004: 页面浏览追踪 - 产品页加载时触发 gtag config', async ({ page }) => {
await page.goto('/products', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 关闭 Cookie 同意横幅
await closeCookieConsent(page);
// 验证页面标题
const pageTitle = page.locator('h1').first();
await expect(pageTitle).toBeVisible({ timeout: 10000 });
// 模拟产品页加载时应触发的 gtag config 调用
await page.evaluate(() => {
(window as any).gtag('config', 'G-TEST123', {
page_path: '/products',
page_title: document.title,
page_location: window.location.origin + '/products',
});
});
// 验证 gtag config 调用
const gtagCalls = await page.evaluate(() => (window as any).__gtagCalls || []);
const configCalls = gtagCalls.filter((call: any[]) => call[0] === 'config');
expect(configCalls.length).toBeGreaterThan(0);
expect(configCalls[0][1]).toBe('G-TEST123');
expect(configCalls[0][2]).toBeDefined();
expect(configCalls[0][2].page_path).toBe('/products');
expect(configCalls[0][2].page_title).toBeTruthy();
});
});
});