Files
hanzo.ai/e2e/gates/sales.spec.ts
zeekay 4382b0cfcf booking: a call with the team is cal.com/hanzo
BOOKING is Hanzo AI's page at cal.com, where a visitor picks the 15- or
30-minute call. The sales form and "Book a 30-minute call" open INTRO, the
30-minute event page, which takes the name, email and notes the form fills
in; the profile does not. A click to cal.com/hanzo is the sales_contacted
moment, as cal.hanzo.ai was.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-25 18:16:35 -07:00

279 lines
15 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { answer } from '../../lib/edge'
import { serveExport } from './export'
import { featured, stories } from '../../lib/data/customers'
import { config, person } from './heard'
/**
* THE SALES PAGE FILES A LEAD AND SAYS SO.
*
* /contact-sales asks five things in one card: name, work email, company, team
* size and what they want to build. The answers become a lead in Hanzo's own
* CRM (POST /v1/marketing/leads), which states it server-side with the visit
* the page sends — consent, the ad click, the browser ids — so the moment
* reaches the ad tags under the lead's own id and our stream is not told twice.
* The card then says the request is in and offers the intro call at
* cal.com/hanzo, filled in with their details and the lead's reference. A lead
* that cannot be filed still counts, and the card offers the call and an email
* that carry the answers. Whatever fills the hidden field is thanked and files
* nothing. Measured on the shipped bytes at a desktop and a phone width, with
* the lead endpoint and the calendar answered here.
*/
const LEAD = 'lead_0123456789abcdef01234567'
type Filed = { body: Record<string, unknown> | null; stream: string[] }
/** Answer the lead endpoint with `status`, the calendar with its own address,
* record what our stream is told, and write every GA4 event and Meta call
* down where it outlives the trip to the calendar. gtag stands up the way
* gtag.js leaves it, so GA4 is the browser's to send. */
async function arrive(page: Page, base: string, status = 201): Promise<Filed> {
const filed: Filed = { body: null, stream: [] }
await person(page)
await config(page)
await page.route('https://www.googletagmanager.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
)
await page.route('https://connect.facebook.net/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
)
await page.route(
(url) => url.pathname === '/v1/event',
(route) => {
filed.stream.push(route.request().postData() ?? '')
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
},
)
await page.route('**/v1/marketing/leads', (route) => {
filed.body = route.request().postDataJSON()
return route.fulfill(
status === 201
? { status, contentType: 'application/json', body: JSON.stringify({ lead: LEAD, stage: 'new' }) }
: { status, contentType: 'application/problem+json', body: '{"title":"Service Unavailable"}' },
)
})
await page.route('https://cal.com/**', (route) =>
// Escaped: `&notes=` read as HTML is the entity `&not`.
route.fulfill({ status: 200, contentType: 'text/html', body: `<title>cal</title><p id="at">${route.request().url().replace(/&/g, '&amp;')}</p>` }),
)
// gtag() pushes its arguments onto dataLayer, which the head keeps if it is
// already there, so an event is written down the moment it is sent. The Pixel
// is the tag manager's to install, after the tag config answers, so its calls
// are recorded by wrapping whatever it installs as `fbq`; the stub's own
// fields (queue, loaded) stay on the stub.
await page.addInitScript(() => {
const layer: unknown[] = []
layer.push = (...items: unknown[]) => {
for (const a of items as ArrayLike<unknown>[]) {
if (a && a[0] === 'event') sessionStorage.setItem('gate.ga', JSON.stringify([a[1], a[2]]))
}
return Array.prototype.push.apply(layer, items)
}
;(window as unknown as { dataLayer: unknown[] }).dataLayer = layer
;(window as unknown as { google_tag_manager: object }).google_tag_manager = { 'G-RBVVVLFZDR': {} }
let fbq: unknown
Object.defineProperty(window, 'fbq', {
configurable: true,
get: () => fbq,
set: (real: unknown) => {
fbq =
typeof real === 'function'
? new Proxy(real, {
apply(target, self, a: unknown[]) {
if (a[0] === 'track') sessionStorage.setItem('gate.meta', JSON.stringify([a[1], a[3]]))
return Reflect.apply(target, self, a)
},
})
: real
},
})
})
await page.goto(`${base}/contact-sales?gclid=G9&utm_source=google&utm_medium=cpc&utm_campaign=brand&coupon=50OFF`, { waitUntil: 'domcontentloaded' })
// The visit is reported by an effect, so once our stream has it the form's
// handlers are attached: a press before that is the bare form.
await expect.poll(() => filed.stream.some((b) => b.includes('"$pageview"')), { timeout: 20_000, message: 'the page reported its visit' }).toBe(true)
return filed
}
/** The form, the way a person fills it. */
async function fill(page: Page) {
await page.getByLabel('Full name').fill('Ada Lovelace')
await page.getByLabel('Work email').fill('[email protected]')
await page.getByRole('textbox', { name: 'Company' }).fill('Analytical Engines')
await page.getByLabel('Team size').selectOption('250-999')
await page.getByLabel('What do you want to build?').fill('A private deployment')
}
/** The page is no wider than the window. */
async function fits(page: Page) {
const [scroll, client] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth])
expect(scroll, 'no sideways scroll').toBeLessThanOrEqual(client)
}
test.describe('the sales page', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
for (const [width, height] of [
[1440, 900],
[390, 844],
] as const) {
test(`at ${width}: files the lead, says so, and offers the calendar filled in`, async ({ page }) => {
await page.setViewportSize({ width, height })
const filed = await arrive(page, base)
const head = page.getByRole('heading', { level: 1, name: 'Talk to sales' })
const card = page.locator('[data-slot="sales-card"]')
await expect(head).toBeVisible()
await expect(page.getByText('Enterprise', { exact: true }).first()).toBeVisible()
await expect(page.locator('[data-slot="sales-features"] li')).toHaveCount(5)
await expect(page.getByRole('link', { name: 'See everything in Enterprise' })).toHaveAttribute('href', '/enterprise')
// Real customers, each opening its own study: the Trusted by marquee (8a5cd309),
// every customer with a mark once for the reader, its looping copy hidden.
const logos = page.locator('[data-marquee] a:not([tabindex="-1"])')
await expect(logos).toHaveCount([...featured, ...stories].filter((s) => s.logo).length)
for (const a of await logos.all()) await expect(a).toHaveAttribute('href', /^\/customers(\/|#)[a-z-]+$/)
await expect(page.getByText('[email protected]', { exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: 'Get Hanzo Team' })).toHaveAttribute('href', 'https://hanzo.team/start')
// The call is optional, and it is the 30-minute intro on cal.com/hanzo.
await expect(card.getByRole('link', { name: 'Or book a 30-minute call' })).toHaveAttribute('href', 'https://cal.com/hanzo/30min')
// Beside the pitch on a desktop; right under the heading on a phone.
const h = (await head.boundingBox())!
const c = (await card.boundingBox())!
const features = (await page.locator('[data-slot="sales-features"]').boundingBox())!
if (width >= 1024) {
expect(c.x, 'the form sits to the right of the heading').toBeGreaterThan(h.x + h.width / 2)
expect(Math.abs(c.y - h.y), 'and level with it').toBeLessThan(200)
} else {
expect(c.y, 'the form comes after the heading').toBeGreaterThan(h.y + h.height)
expect(c.y, 'and before the features').toBeLessThan(features.y)
}
// The funnel's measure: every field 56px, the select included (WebKit draws a
// native menu list at 26px unless told otherwise), and the call a 56px pill
// (a touch screen's tap-target rule may hold a pill at its 44px floor).
for (const id of ['sales-name', 'sales-email', 'sales-company', 'sales-size'])
expect(Math.round((await page.locator(`#${id}`).boundingBox())!.height), `#${id} is 56px`).toBe(56)
const submit = card.getByRole('button', { name: 'Talk to sales' })
expect(Math.round((await submit.boundingBox())!.height)).toBeGreaterThanOrEqual(width >= 1024 ? 56 : 44)
await fits(page)
// Nothing is sent until every required answer holds, and the first one missing takes the focus.
await submit.click()
for (const said of ['Enter your name.', 'Enter a work email, like [email protected].', 'Enter your company.', 'Choose a team size.'])
await expect(card.getByText(said, { exact: true })).toBeVisible()
await expect(page.getByLabel('Full name')).toBeFocused()
await expect(page.getByLabel('Full name')).toHaveAttribute('aria-invalid', 'true')
// An address that is not one is said wrong as the field is left.
await page.getByLabel('Work email').fill('ada@analytical')
await page.getByLabel('Full name').focus()
await expect(page.getByLabel('Work email')).toHaveAttribute('aria-describedby', 'sales-email-wrong')
expect(filed.body, 'nothing was sent').toBeNull()
await fill(page)
await fits(page)
await submit.click()
await expect(card).toHaveAttribute('data-state', 'sent')
await expect(card.getByRole('heading', { name: 'Thanks, Ada. Your request is in.' })).toBeVisible()
await expect(card).toContainText('[email protected]')
await expect(card).toBeFocused()
await fits(page)
const { visit, ...form } = filed.body as Record<string, unknown>
expect(form, 'the lead endpoint heard the whole form').toEqual({
email: '[email protected]',
name: 'Ada Lovelace',
company: 'Analytical Engines',
size: '250-999',
need: 'A private deployment',
source: 'hanzo.ai/contact-sales',
campaign: { utm_source: 'google', utm_medium: 'cpc', utm_campaign: 'brand' },
coupon: '50OFF',
})
expect(visit, 'and the visit it forwards the lead with').toMatchObject({
gclid: 'G9',
consent: 'analytics,marketing,ads',
tags: 'ga,meta',
userAgent: expect.any(String),
url: expect.stringContaining('/contact-sales?gclid=G9'),
})
expect((visit as { fbp?: string }).fbp).toMatch(/^fb\.1\./)
const at = new URL((await card.getByRole('link', { name: 'Book a call' }).getAttribute('href'))!)
expect(`${at.origin}${at.pathname}`).toBe('https://cal.com/hanzo/30min')
expect(at.searchParams.get('name')).toBe('Ada Lovelace')
expect(at.searchParams.get('email')).toBe('[email protected]')
expect(at.searchParams.get('notes')).toContain('Analytical Engines')
expect(at.searchParams.get('notes'), 'the booking names its lead').toContain(LEAD)
const [name, params] = JSON.parse((await page.evaluate(() => sessionStorage.getItem('gate.ga')))!)
expect(name, 'GA4 heard the lead').toBe('generate_lead')
expect(params).toMatchObject({ lead_id: LEAD, event_id: LEAD })
const [meta, options] = JSON.parse((await page.evaluate(() => sessionStorage.getItem('gate.meta')))!)
expect(meta, 'Meta heard it').toBe('Lead')
expect(options.eventID, 'under the id cloud states it with, so Meta keeps one').toBe(LEAD)
expect(filed.stream.some((b) => b.includes('"sales_contacted"')), 'our stream is not told twice').toBe(false)
})
}
test('a lead that cannot be filed still reaches a person, with the answers', async ({ page }) => {
const filed = await arrive(page, base, 503)
await fill(page)
await page.getByRole('button', { name: 'Talk to sales' }).click()
const card = page.locator('[data-slot="sales-card"]')
await expect(card).toHaveAttribute('data-state', 'held')
await expect(card.getByRole('heading', { name: 'One more step' })).toBeVisible()
expect(filed.body?.email).toBe('[email protected]')
const to = new URL((await card.getByRole('link', { name: 'Pick a time' }).getAttribute('href'))!)
expect(`${to.origin}${to.pathname}`).toBe('https://cal.com/hanzo/30min')
expect(to.searchParams.get('email')).toBe('[email protected]')
expect(to.searchParams.get('name')).toBe('Ada Lovelace')
const mail = (await card.getByRole('link', { name: /^Or email enterprise@hanzo\.ai/ }).getAttribute('href'))!
expect(mail).toMatch(/^mailto:enterprise@hanzo\.ai\?/)
expect(decodeURIComponent(mail)).toContain('Analytical Engines')
// Unfiled, the lead still counts, under an id of its own.
const [name, params] = JSON.parse((await page.evaluate(() => sessionStorage.getItem('gate.ga')))!)
expect(name).toBe('generate_lead')
expect(params.lead_id, 'no lead id to name').toBeUndefined()
await expect.poll(() => filed.stream.some((b) => b.includes('"sales_contacted"')), { timeout: 20_000 }).toBe(true)
})
test('whatever fills the hidden field is thanked, files nothing, and is counted', async ({ page }) => {
const filed = await arrive(page, base)
await fill(page)
// Set the way a script sets it, with no input event for the page to hear.
await page.locator('input[name="website"]').evaluate((el: HTMLInputElement) => (el.value = 'https://spam.example'))
await page.getByRole('button', { name: 'Talk to sales' }).click()
await expect(page.locator('[data-slot="sales-card"]')).toHaveAttribute('data-state', 'sent')
expect(filed.body, 'no lead was sent').toBeNull()
await expect.poll(() => filed.stream.some((b) => b.includes('"sales_trapped"')), { timeout: 20_000 }).toBe(true)
})
test('answers an earlier form left in the address are taken out of it, and the click is kept', async ({ page }) => {
await arrive(page, base)
await page.goto(`${base}/contact-sales?name=Ada+Lovelace&email=ada%40analytical.co&company=AE&gclid=G9`, { waitUntil: 'domcontentloaded' })
await expect.poll(() => new URL(page.url()).search, { timeout: 20_000 }).toBe('?gclid=G9')
})
test.describe('before the page runs', () => {
test.use({ javaScriptEnabled: false })
test('the export\'s form sends nothing, and puts nothing in the address', async ({ page }) => {
await page.goto(`${base}/contact-sales`, { waitUntil: 'domcontentloaded' })
const submit = page.locator('[data-slot="sales-card"] button[type="submit"]')
await expect(submit).toBeDisabled()
await page.locator('#sales-email').fill('[email protected]')
await page.locator('#sales-email').press('Enter')
await page.waitForTimeout(500)
expect(page.url(), 'no answer in the address').not.toContain('email=')
})
})
test('the old address forwards here', () => {
expect(answer('https://hanzo.ai/contact/sales?plan=team')).toEqual({ location: 'https://hanzo.ai/contact-sales?plan=team' })
})
})