- 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 配置收敛。
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
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');
|
||
});
|
||
});
|