Files
hanzo.ai/e2e/gates/heard.ts
zeekay b99057c135 analytics: each host asks for its own tag set
The export is served on hanzo.app and hanzo.chat as well as hanzo.ai, and
startTags was handed hanzo.ai's project key, so every host fetched
hanzo.ai's tag set. Leaving the key out lets @hanzo/event resolve it from
the host (siteKey).

The gates' tag-set stub answers by the key it is asked with, as cloud
does, so get.spec fails when a host asks with another project's key.
2026-10-25 19:00:45 -07:00

102 lines
3.9 KiB
TypeScript

import { expect, type Page } from '@playwright/test'
import { SITE_KEY } from '@hanzo/event'
/**
* What the site told its counters, read in a gate: the events our stream
* posted to /v1/event (recorded and answered here, never sent), and the GA4
* events gtag was handed.
*/
export type Wire = { event?: string; type?: string; properties?: Record<string, unknown> }
/** Each project's GA4 stream, by the key cloud finds its tag set with; any other key is hanzo.ai's. */
const STREAM: Record<string, string> = {
[SITE_KEY['hanzo.chat']]: 'G-GRQBMNDTT6',
[SITE_KEY['hanzo.app']]: 'G-P3G0R2PX3D',
}
/**
* A person's browser: `navigator.webdriver` off. Playwright sets it, and a driven
* browser is a program to the page — @hanzo/event loads it no platform pixel and
* marks its events internal, and the sign-in pages never ask IAM about it. A gate
* whose subject is what a visitor's browser does starts here.
*/
export const person = (page: Page) => page.addInitScript(() => Object.defineProperty(Navigator.prototype, 'webdriver', { get: () => false }))
/**
* The site's tag set and the consent policy, as cloud serves them at
* GET /v1/project/tags (answered here, never fetched): the GA4 stream of the
* project the request's key names, and the Meta Pixel, and opt-in for every
* visitor with nothing on before a choice.
*/
export async function config(page: Page): Promise<void> {
await page.route(
(url) => url.pathname === '/v1/project/tags',
(route) => {
const key = new URL(route.request().url()).searchParams.get('key') ?? ''
const id = STREAM[key] ?? 'G-RBVVVLFZDR'
return route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'access-control-allow-origin': '*' },
body: JSON.stringify({
tags: [
{ platform: 'ga4', type: 'ga', id },
{ platform: 'meta', type: 'meta', id: '1790308611893001' },
],
consent: { region: 'US', mode: 'opt-in', version: 2, refused: false, defaults: { analytics: false, marketing: false, ads: false } },
}),
})
},
)
}
/**
* Records every event the stream posts, and answers it, and serves the tag set.
* Routed last wins, so this is called after any catch-all route a spec lays down.
*/
export async function stream(page: Page): Promise<Wire[]> {
await config(page)
const seen: Wire[] = []
await page.route(
(url) => url.pathname === '/v1/event',
(route) => {
try {
const body = JSON.parse(route.request().postData() ?? '{}') as { batch?: Wire[] } | Wire[]
seen.push(...(Array.isArray(body) ? body : (body.batch ?? [body as Wire])))
} catch {
/* a body that is not JSON records nothing */
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"accepted":1}' })
},
)
return seen
}
/** The first stream event by name (and `where`), once the SDK has flushed it. */
export async function heard(seen: Wire[], name: string, where: (p: Record<string, unknown>) => boolean = () => true) {
const find = () => seen.find((e) => e.event === name && where(e.properties ?? {}))
await expect.poll(find, { timeout: 20_000, message: `${name} reached the stream` }).toBeTruthy()
return find()!.properties ?? {}
}
/** The GA4 events gtag was handed: [name, params]. */
export const ga = (page: Page) =>
page.evaluate(() =>
((window as unknown as { dataLayer?: IArguments[] }).dataLayer ?? [])
.map((a) => Array.from(a))
.filter((a) => a[0] === 'event')
.map((a) => [a[1], a[2]] as [string, Record<string, unknown>]),
)
/**
* An address without the visitor it carries: a link to another Hanzo host rides
* `hz_aid`, `hz_sid` and `hz_ft` in its query (@hanzo/event `link`), and a
* gate about where the browser went reads the address underneath.
*/
export function bare(href: string): string {
const u = new URL(href)
for (const k of ['hz_aid', 'hz_sid', 'hz_ft']) u.searchParams.delete(k)
return u.href
}