Files
novalon-website/e2e/ga4-event-tracking.spec.ts
T
zhangxiang 6bb7c557ee 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
  配置收敛。
2026-09-28 10:48:08 +08:00

261 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from './fixtures';
/**
* GA4 事件追踪验证
*
* 验收 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: 首屏内联片段发出 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' });
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 为空)');
const calls = await expectSnippetCalls(page);
expect(callsWith(calls, 'js')).toHaveLength(1);
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');
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 }) => {
await seedConsent(page);
await page.route('**/api/contact', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: 'true' }),
})
);
const measurementId = await gotoWithGtag(page, '/contact');
test.skip(measurementId === null, '构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)');
await expectGtagReady(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 submitButton = page.locator('[data-testid="submit-button"]').first();
await expect(submitButton).toBeEnabled({ timeout: 10000 });
await submitButton.click();
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 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);
}
const consentUpdate = callsWith(calls, 'consent', 'update');
expect(consentUpdate.length).toBeGreaterThan(0);
expect(params(consentUpdate[consentUpdate.length - 1]).analytics_storage).toBe('granted');
await expect(acceptAll).toBeHidden({ timeout: 10000 });
});
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);
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'
);
// 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');
});
});