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; /** 内联片段把参数按 arguments 对象压入队列,这里统一展开成数组便于断言。 */ async function gtagCalls(page: Page): Promise { return page.evaluate(() => { const queue = (window as unknown as { dataLayer?: ArrayLike[] }).dataLayer; if (!queue) {return [];} return Array.from(queue).map((entry) => Array.from(entry as ArrayLike)); }); } 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 { 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 { 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 { await page.addInitScript(() => { window.localStorage.removeItem('novalon-cookie-preferences'); }); } async function expectGtagReady(page: Page): Promise { await expect .poll(async () => (await gtagCalls(page)).length, { timeout: 15000 }) .toBeGreaterThan(0); } /** 等应用内联片段把 js / consent default / config 三条都写入队列后再断言,避免读到半成品。 */ async function expectSnippetCalls(page: Page): Promise { 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 { 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'); }); });