Files
hanzo.ai/e2e/gates/conversion.spec.ts
zeekay 8bb3c633d2 consent: @hanzo/ui's one sheet, and our stream counts every visitor
components/consent/Consent.tsx and Choices.tsx are gone. app/providers.tsx
mounts Consent from @hanzo/ui/consent 8.29.14: a bar with Manage
preferences, Accept all and a × that stores essential cookies only, and
a Cookie Preferences sheet that opens from any link to #choices, the
footer's Privacy choices included. Its switches start off until chosen.

@hanzo/event 0.3.61 makes the stream first party: every page view and
event goes for every visitor, stating the grants it went under, so the
provider no longer takes `enabled` from consent. Consent gates Google
and Meta alone, through startTags.

The conversion and forward gates now hold that no platform hears of a
visit before Accept or after a refusal, while our stream counts it under
no grant. Help's corner words gain the Font and Arabic font rows
@hanzo/ui 8.29.12 added to Words.
2026-10-25 22:05:45 -07:00

420 lines
23 KiB
TypeScript
Raw Permalink 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 '@playwright/test'
import { secure, serveExport } from './export'
import { serve } from './api'
import { config, ga, heard, person, stream } from './heard'
/**
* A CONVERSION, COUNTED ONCE IN EVERY PLACE IT IS COUNTED.
*
* Every funnel moment goes through one call (lib/analytics/tags.ts track) that
* reaches our stream, GA4 and Meta under one event_id, with the visitor's
* consent and the ad click they came through. cloud forwards the stream's copy
* server-side (apps/destination), so these hold the browser half of that
* contract against `out/`, the bytes that ship:
*
* the click is captured first-party, and every event carries it;
* GA4 is sent each event exactly once — by gtag when gtag is running, and
* otherwise by nobody in the browser, so cloud sends it instead;
* our own stream is first party and counts every visitor from the first page,
* whatever they chose; each event states the grants it went under;
* every visitor, whatever their clock says, is asked first: nothing reaches a
* platform (Google, Meta) before Accept, and nothing after the bar's ×, which
* is the refusal (essential cookies only);
* Cookie settings open from `#choices`, all off until chosen, and a choice
* decides what loads;
* Global Privacy Control and Do Not Track hold every platform off.
*
* The network is ours: gtag.js and fbevents.js are answered with stand-ins, and
* /v1/event is recorded rather than sent.
*/
const GA = 'G-RBVVVLFZDR'
/** The sheet's three switches (@hanzo/ui/consent): analytics, marketing, ads. */
const PERFORMANCE = 'Performance Cookies'
const MARKETING = 'Marketing Cookies'
const ADS = 'Sell, Share, or Use Your Data for Targeted Advertising'
const OPTIONAL = [PERFORMANCE, MARKETING, ADS]
/** Answers the two ad libraries. `live` stands gtag.js up the way it would be. */
async function tags(page: Page, live: boolean): Promise<string[]> {
const fetched: string[] = []
// The plans are the plan authority's, read when /pricing opens (e2e/gates/api).
await serve(page)
await person(page)
await config(page)
await page.route('https://www.googletagmanager.com/**', (route) => {
fetched.push('gtag')
return live
? route.fulfill({ status: 200, contentType: 'text/javascript', body: `window.google_tag_manager={'${GA}':{}}` })
: route.abort()
})
await page.route('https://connect.facebook.net/**', (route) => {
fetched.push('fbevents')
return route.fulfill({ status: 200, contentType: 'text/javascript', body: '' })
})
await page.route(/pay\.hanzo\.ai/, (route) => route.fulfill({ status: 200, contentType: 'text/html', body: '<title>pay</title>' }))
if (live) await page.addInitScript((ga) => ((window as unknown as Record<string, unknown>).google_tag_manager = { [ga]: {} }), GA)
return fetched
}
/** The Meta calls the Pixel queued: [command, name, params, options]. */
const meta = (page: Page) =>
page.evaluate(() =>
((window as unknown as { fbq?: { queue?: IArguments[] } }).fbq?.queue ?? []).map((a) => Array.from(a)),
)
test.describe('conversions', () => {
// A visitor who accepted everything (playwright.config ANSWERED); the cookie
// question itself starts from a browser that has answered none, below.
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('a moment fired before gtag.js answers waits for it, and then goes through gtag', async ({ page }) => {
await serve(page)
await person(page)
const seen = await stream(page)
// gtag.js arrives a second and a half after it is asked for, as a real one
// on a slow line does. pricing_viewed fires at hydration, before that.
await page.route('https://www.googletagmanager.com/**', async (route) => {
await new Promise((r) => setTimeout(r, 1500))
return route.fulfill({ status: 200, contentType: 'text/javascript', body: `window.google_tag_manager={'${GA}':{}}` })
})
await page.route('https://connect.facebook.net/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
)
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
const view = await heard(seen, 'pricing_viewed')
expect(view.tags, 'held for gtag, so the browser took the GA4 path').toBe('ga,meta')
const list = (await ga(page)).find(([name]) => name === 'view_item_list')
expect(list?.[1].event_id).toBe(view.event_id)
})
test('a landing click is kept first-party, and the list view carries it under one event_id', async ({ page, context }) => {
const seen = await stream(page)
await tags(page, true)
await page.goto(`${base}/pricing?gclid=G1&fbclid=F1&utm_source=google&utm_medium=cpc`, { waitUntil: 'load' })
const view = await heard(seen, 'pricing_viewed')
const jar = Object.fromEntries((await context.cookies()).map((c) => [c.name, decodeURIComponent(c.value)]))
expect(JSON.parse(jar.hz_touch)).toMatchObject({ gclid: 'G1', fbclid: 'F1' })
expect(jar._fbc).toMatch(/^fb\.1\.\d{13}\.F1$/)
expect(jar._fbp).toMatch(/^fb\.1\.\d{13}\.\d+$/)
expect(view).toMatchObject({
gclid: 'G1',
fbclid: 'F1',
fbc: jar._fbc,
fbp: jar._fbp,
consent: 'analytics,marketing,ads',
tags: 'ga,meta',
})
expect(view.cid, 'a GA4 client id, minted first-party when _ga is absent').toMatch(/^\d+\.\d+$/)
expect(typeof view.clicked).toBe('number')
expect((view.items as { item_id: string }[])[0].item_id).toBeTruthy()
const id = view.event_id
expect(id).toMatch(/^[0-9a-f-]{36}$/)
const list = (await ga(page)).find(([name]) => name === 'view_item_list')
expect(list?.[1].event_id, 'GA4 heard the same moment').toBe(id)
const content = (await meta(page)).find((c) => c[0] === 'track' && c[1] === 'ViewContent')
expect((content?.[3] as { eventID?: string })?.eventID, 'Meta heard it under the same id').toBe(id)
})
test('the plan CTA is a selection and a checkout, each one event_id everywhere', async ({ page }) => {
const seen = await stream(page)
await tags(page, true)
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
// The view is reported by an effect, so once it is heard the page is hydrated
// and the CTA's own handler is attached; a press before that is the bare link.
await heard(seen, 'pricing_viewed')
// Sign-up first, then pay: a card plan's CTA is /signup carrying its checkout
// (components/pricing/Plans.tsx `bought`).
const cta = page.locator('main a[data-ga][href*="pay%2Fconfirm"]').first()
await expect(cta).toBeVisible({ timeout: 15_000 })
await page.evaluate(() => document.addEventListener('click', (e) => e.preventDefault(), { capture: true, once: true }))
// The anchor's own click: what a press on it sends, whatever the layout lays over it.
await cta.evaluate((a) => (a as HTMLElement).click())
const pick = await heard(seen, 'plan_clicked')
const buy = await heard(seen, 'checkout_started')
expect(buy).toMatchObject({ currency: 'USD', tags: 'ga,meta' })
expect(typeof buy.value).toBe('number')
const events = await ga(page)
expect(events.filter(([n]) => n === 'select_item').map(([, p]) => p.event_id)).toEqual([pick.event_id])
expect(events.filter(([n]) => n === 'begin_checkout').map(([, p]) => p.event_id), 'one begin_checkout, not one per listener').toEqual([buy.event_id])
const initiate = (await meta(page)).filter((c) => c[1] === 'InitiateCheckout')
expect(initiate.map((c) => (c[3] as { eventID: string }).eventID)).toEqual([buy.event_id])
expect(seen.filter((e) => e.event === 'checkout_started'), 'the click-out handler left it to the CTA').toHaveLength(1)
})
test('with gtag.js blocked nothing is queued for it, and the event says so for the server', async ({ page }) => {
const seen = await stream(page)
await tags(page, false)
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
const view = await heard(seen, 'pricing_viewed')
expect(view.tags, 'gtag never ran, so GA4 is the server\'s to send').toBe('meta')
expect(await ga(page), 'nothing waits in dataLayer to double-count later').toEqual([])
})
test.describe('the cookie question', () => {
// On the real host, over https: the page the reader gets, whose choice is kept
// under __Host- names (export.ts secure).
test.use({ storageState: { cookies: [], origins: [] }, timezoneId: 'America/Los_Angeles' })
let local: string
test.beforeEach(async ({ page }) => {
local = base
base = await secure(page, local)
})
test.afterEach(() => {
base = local
})
/** Every request the page makes to a third party that measures it. Our own stream is not one. */
const MEASURES = /googletagmanager|google-analytics|doubleclick|googleadservices|facebook\.(net|com)|licdn|linkedin|ads-twitter|tiktok|redditstatic|bing\.com/
const watch = (page: Page): string[] => {
const out: string[] = []
page.on('request', (r) => {
if (MEASURES.test(r.url())) out.push(r.url())
})
return out
}
/**
* What a page may not keep before a choice, nor after a refusal: a platform's ids
* (_ga*, _gid, _gat*, _gcl*, _gac*, _fb*, _tt*, ttcsid*, _twclid, li_*, _uet*) and
* the two of ours kept for one (hz_cid for Google Analytics, hz_touch the ad
* click). Our stream's own id, session and touches are first party and stay.
*/
const MEASURING = /^(hz_cid|hz_touch|_ga|_gid|_gat|_gcl|_gac|_fb|_tt|ttcsid|_twclid|twclid|li_|_uet|_scid|_rdt)/
/** What a visitor typed, held for the trip to sign in (components/shell/prompt.ts): theirs, not a measure. */
const THEIRS = new Set(['hz_prompt'])
const measuring = (names: string[]) => names.filter((n) => MEASURING.test(n) && !THEIRS.has(n))
/** What the page keeps on the device: cookies, localStorage and sessionStorage keys. */
const kept = async (page: Page) => ({
cookies: measuring((await page.context().cookies()).map((c) => c.name)),
local: measuring(await page.evaluate(() => Object.keys(window.localStorage))),
session: measuring(await page.evaluate(() => Object.keys(window.sessionStorage))),
})
/** The stream's events that went under a grant: none, for a visitor who allowed nothing. */
const granted = (seen: { properties?: Record<string, unknown> }[]) => seen.filter((e) => String(e.properties?.consent ?? '') !== '')
/**
* No platform heard of the visit and nothing of theirs was kept on the device, while
* our own stream counted it under no grant: waited for past one flush of the client
* (5 s), not just looked at.
*/
const silent = async (page: Page, seen: { event?: string; properties?: Record<string, unknown> }[], fetched: string[], measured: string[]) => {
await page.waitForTimeout(6_000)
expect(fetched, 'nothing from a platform').toEqual([])
expect(measured, 'no request to a platform').toEqual([])
expect(await kept(page), 'nothing of a platform kept').toEqual({ cookies: [], local: [], session: [] })
expect(seen.map((e) => e.event), 'our own stream counts the visit').toContain('$pageview')
expect(granted(seen), 'and states no grant').toEqual([])
}
test('a visitor on a US clock is asked, and no platform loads or hears until they accept', async ({ page }) => {
const seen = await stream(page)
const fetched = await tags(page, true)
const measured = watch(page)
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
const banner = page.getByRole('dialog', { name: 'Cookie preferences' })
await expect(banner).toBeVisible({ timeout: 15_000 })
await silent(page, seen, fetched, measured)
await banner.getByRole('button', { name: 'Accept all' }).click()
await expect.poll(() => fetched, { message: 'accepting loads the tags with no reload' }).toContain('gtag')
expect(fetched).toContain('fbevents')
const view = await heard(seen, 'pricing_viewed', (p) => p.consent === 'analytics,marketing,ads')
expect(view.consent).toBe('analytics,marketing,ads')
await heard(seen, '$pageview')
await page.waitForTimeout(6_000)
expect(seen.filter((e) => e.event === '$pageview' && (e as { path?: string }).path === '/pricing'), 'the view is counted once').toHaveLength(1)
})
test('the bar\'s × is a refusal: essential cookies only, nothing sent, nothing loaded', async ({ page }) => {
const seen = await stream(page)
const fetched = await tags(page, true)
const measured = watch(page)
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
const banner = page.getByRole('dialog', { name: 'Cookie preferences' })
await expect(banner).toBeVisible({ timeout: 15_000 })
await banner.getByRole('button', { name: 'Close and use essential cookies only' }).click()
await expect(banner).toHaveCount(0)
await silent(page, seen, fetched, measured)
const choice = (await page.context().cookies()).filter((c) => c.name.startsWith('__Host-hz_consent'))
expect(choice.map((c) => [c.name, c.value, c.secure, c.domain]), 'the choice is the host\'s own').toEqual([
['__Host-hz_consent', '', true, 'hanzo.ai'],
['__Host-hz_consent_v', '2', true, 'hanzo.ai'],
])
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
await expect(page.getByRole('dialog', { name: 'Cookie preferences' }), 'a choice is not asked again').toHaveCount(0)
await silent(page, seen, fetched, measured)
})
for (const when of ['after the page settled', 'within one flush of the client'] as const) {
test(`a visitor who accepts and then switches everything off ${when} is measured no more: the page reloads itself, and keeps nothing`, async ({ page }) => {
const seen = await stream(page)
const fetched = await tags(page, true)
const measured = watch(page)
await page.goto(`${base}/pricing?gclid=G1&fbclid=F1&utm_source=google`, { waitUntil: 'load' })
await page.getByRole('dialog', { name: 'Cookie preferences' }).getByRole('button', { name: 'Accept all' }).click()
await expect.poll(() => fetched, { message: 'Accept loads the libraries' }).toContain('fbevents')
if (when === 'after the page settled') {
await heard(seen, 'pricing_viewed', (p) => p.consent === 'analytics,marketing,ads')
await page.waitForTimeout(6_000)
}
// Everything from here on is after the visitor's refusal, on the same page and then the next.
const mark = { seen: seen.length, fetched: fetched.length, measured: measured.length }
await page.locator('footer[data-slot="footer"] a[href="#choices"]').click()
const panel = page.getByRole('dialog', { name: 'Cookie preferences' })
for (const name of OPTIONAL) await panel.getByRole('switch', { name, exact: true }).uncheck()
const reloaded = page.waitForEvent('load')
await panel.getByRole('button', { name: 'Save preferences' }).click()
await reloaded
await expect(page.getByRole('dialog', { name: 'Cookie preferences' }), 'the choice holds').toHaveCount(0)
await page.waitForTimeout(6_000)
expect(granted(seen.slice(mark.seen)), 'our own stream goes on, under no grant').toEqual([])
expect(fetched.slice(mark.fetched), 'no platform library after the refusal').toEqual([])
expect(measured.slice(mark.measured), 'no request to a platform after the refusal').toEqual([])
expect(await kept(page), 'nothing of a platform kept').toEqual({ cookies: [], local: [], session: [] })
})
}
test('a visitor who withdraws Marketing alone within one flush sends no ad platform anything more', async ({ page }) => {
const seen = await stream(page)
const fetched = await tags(page, true)
await page.goto(`${base}/pricing?gclid=G1&fbclid=F1&utm_source=google`, { waitUntil: 'load' })
await page.getByRole('dialog', { name: 'Cookie preferences' }).getByRole('button', { name: 'Accept all' }).click()
await expect.poll(() => fetched, { message: 'Accept loads the libraries' }).toContain('fbevents')
const mark = { seen: seen.length, fetched: fetched.length }
await page.locator('footer[data-slot="footer"] a[href="#choices"]').click()
const panel = page.getByRole('dialog', { name: 'Cookie preferences' })
await panel.getByRole('switch', { name: MARKETING, exact: true }).uncheck()
await panel.getByRole('switch', { name: ADS, exact: true }).uncheck()
const reloaded = page.waitForEvent('load')
await panel.getByRole('button', { name: 'Save preferences' }).click()
await reloaded
await page.waitForTimeout(6_000)
const after = seen.slice(mark.seen)
expect(after.filter((e) => String(e.properties?.consent ?? '').includes('marketing')), 'nothing sent under the withdrawn grant').toEqual([])
expect(after.some((e) => e.properties?.consent === 'analytics'), 'Analytics goes on').toBe(true)
expect(fetched.slice(mark.fetched), 'no ad library after the withdrawal').not.toContain('fbevents')
const ids = (await page.context().cookies()).map((c) => c.name).filter((n) => /^(_fb|_gcl|hz_touch)/.test(n))
expect(ids, 'no ad id kept').toEqual([])
})
test('a refusal cloud saw on the request (Sec-GPC) is a refusal: not asked, no platform hears', async ({ page }) => {
const seen = await stream(page)
const fetched = await tags(page, true)
const measured = watch(page)
// Registered last, so it answers first: cloud's policy for a request that carried Sec-GPC.
await page.route(
(url) => url.pathname === '/v1/project/tags',
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'access-control-allow-origin': '*' },
body: JSON.stringify({
tags: [{ platform: 'ga4', type: 'ga', id: GA }, { platform: 'meta', type: 'meta', id: '1790308611893001' }],
consent: { region: 'US', mode: 'opt-in', version: 2, refused: true, defaults: { analytics: false, marketing: false, ads: false } },
}),
}),
)
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
await page.waitForTimeout(2_000)
await expect(page.getByRole('dialog', { name: 'Cookie preferences' }), 'the browser already answered').toHaveCount(0)
await silent(page, seen, fetched, measured)
})
test('Cookie settings open from #choices, all off, and a choice decides what loads', async ({ page }) => {
const seen = await stream(page)
const fetched = await tags(page, true)
await page.goto(`${base}/pricing#choices`, { waitUntil: 'load' })
const panel = page.getByRole('dialog', { name: 'Cookie preferences' })
await expect(panel).toBeVisible({ timeout: 15_000 })
for (const name of OPTIONAL)
await expect(panel.getByRole('switch', { name, exact: true }), `${name} starts off`).not.toBeChecked()
await panel.getByRole('switch', { name: PERFORMANCE, exact: true }).check()
await panel.getByRole('switch', { name: ADS, exact: true }).check()
await panel.getByRole('button', { name: 'Save preferences' }).click()
await expect(page.getByRole('dialog', { name: 'Cookie preferences' }), 'saving closes it').toHaveCount(0)
// The next page load reads the stored choice.
fetched.length = 0
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
const view = await heard(seen, 'pricing_viewed', (p) => p.consent === 'analytics,ads')
expect(view.consent).toBe('analytics,ads')
expect(view.gclid ?? view.fbp, 'no ad ids without Marketing').toBeUndefined()
expect(fetched, 'the Pixel is never fetched without Marketing').not.toContain('fbevents')
expect(await page.evaluate(() => typeof (window as unknown as { fbq?: unknown }).fbq)).toBe('undefined')
const consent = await page.evaluate(() =>
((window as unknown as { dataLayer: IArguments[] }).dataLayer ?? [])
.map((a) => Array.from(a))
.find((a) => a[0] === 'consent' && a[1] === 'default')?.[2],
)
expect(consent).toMatchObject({ analytics_storage: 'denied', ad_storage: 'denied' })
})
test('a choice made under the opt-out policy, or set by another host, is asked again, and counts for nothing meanwhile', async ({ page, context }) => {
const seen = await stream(page)
const fetched = await tags(page, true)
const measured = watch(page)
// The old policy's yes under this host's own names, and a current-looking yes
// under the bare names a sibling host could have set: neither is a choice.
await context.addCookies([
...[['__Host-hz_consent', 'analytics,marketing,ads'], ['__Host-hz_consent_v', '1']].map(([name, value]) => ({ name, value, url: `${base}/`, secure: true })),
...[['hz_consent', 'analytics,marketing,ads'], ['hz_consent_v', '2']].map(([name, value]) => ({ name, value, domain: '.hanzo.ai', path: '/' })),
])
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
await expect(page.getByRole('dialog', { name: 'Cookie preferences' })).toBeVisible({ timeout: 15_000 })
await page.waitForTimeout(6_000)
expect(fetched).toEqual([])
expect(measured).toEqual([])
expect(granted(seen), 'our own stream counts the visit under no grant').toEqual([])
})
test('the footer row is the control', async ({ page }) => {
await tags(page, true)
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
await page.getByRole('dialog', { name: 'Cookie preferences' }).getByRole('button', { name: 'Close and use essential cookies only' }).click()
await page.locator('footer[data-slot="footer"] a[href="#choices"]').click()
await expect(page.getByRole('dialog', { name: 'Cookie preferences' })).toBeVisible()
})
for (const [signal, script] of [
['Global Privacy Control', () => Object.defineProperty(Navigator.prototype, 'globalPrivacyControl', { get: () => true })],
['Do Not Track', () => Object.defineProperty(Navigator.prototype, 'doNotTrack', { get: () => '1' })],
] as const) {
test(`${signal} is a refusal: not asked, no platform hears, every switch held off`, async ({ page }) => {
const seen = await stream(page)
const fetched = await tags(page, true)
const measured = watch(page)
await page.addInitScript(script)
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
await expect(page.getByRole('dialog', { name: 'Cookie preferences' }), 'the browser already answered').toHaveCount(0)
await silent(page, seen, fetched, measured)
await page.goto(`${base}/pricing#choices`, { waitUntil: 'load' })
const panel = page.getByRole('dialog', { name: 'Cookie preferences' })
await expect(panel).toBeVisible({ timeout: 15_000 })
for (const name of OPTIONAL) {
const on = panel.getByRole('switch', { name, exact: true })
await expect(on).not.toBeChecked()
await expect(on, `${name} cannot be switched on`).toBeDisabled()
}
await expect(panel.getByText(/asks sites not to track you/)).toBeVisible()
await silent(page, seen, fetched, measured)
})
}
})
})