Files
hanzo.ai/e2e/funnel.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

257 lines
12 KiB
TypeScript

import { expect, test, type Locator, type Page } from '@playwright/test'
import { readFileSync } from 'node:fs'
import { issuer, pkce } from './gates/issuer'
/**
* THE FUNNEL, CLICKED.
*
* A buyer's path through hanzo.ai, one control at a time: the header's two
* doors, the plan and its checkout, the sales call, the contact page, the
* footer's docs, status and legal, and the documents a buyer is asked to
* accept. Every test presses the control and asserts where the browser went —
* not that a status code came back, which a page that goes nowhere also
* returns.
*
* Off-site hosts are answered here. The assertion is about THIS site: that the
* control sends the reader to the right address. Whether that host is up is
* somebody else's uptime, and checking it would make a local run depend on the
* network. IAM is answered by the same canned issuer the sign-in gate uses,
* measured against the live one.
*
* Run against `pnpm dev` on any port:
*
* BASE_URL=http://localhost:4317 pnpm exec playwright test --project=chromium e2e/funnel.spec.ts
*/
// A dev server compiles each route on its first request, and a cold compile of
// a marketing page takes longer than the 30s default on a loaded machine.
test.describe.configure({ timeout: 180_000 })
/** Answer these hosts with an empty page, so a click lands and can be read —
* on the context, because a control that opens a new tab opens a new page. */
async function offsite(page: Page, ...hosts: string[]): Promise<void> {
for (const host of hosts) {
await page.context().route(`https://${host}/**`, (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>off-site</title>' }),
)
}
}
/** Click, and resolve with the address the browser arrived at. Committed,
* not loaded: where the browser went is the question, and a dev server may
* still be compiling the page it went to. */
async function follow(page: Page, control: Locator): Promise<URL> {
const before = page.url()
await control.click()
await page.waitForURL((url) => url.toString() !== before, { timeout: 120_000, waitUntil: 'commit' })
return new URL(page.url())
}
/** Where a tab landed, without its query: GA4's cross-domain linker adds a
* `_gl` parameter to links into docs.hanzo.ai and cal.com on a shipped
* build, and the destination is the question, not the decoration. */
const place = (u: string) => {
const { origin, pathname } = new URL(u)
return `${origin}${pathname}`
}
/** A link in the page body by its text. By element, not by role: @hanzo/ui's
* `Button asChild` stamps `role="button"` on the anchor it wraps, so a query
* for the link role misses every pricing control. */
const link = (page: Page, text: RegExp) => page.locator('main a[href]', { hasText: text }).first()
const go = (page: Page, path: string) => page.goto(path, { waitUntil: 'domcontentloaded', timeout: 150_000 })
test('/login starts an authorization-code + PKCE trip at hanzo.id', async ({ page }) => {
const seen = await issuer(page)
const base = new URL(test.info().project.use.baseURL ?? 'http://localhost:8084').origin
await go(page, '/login')
await expect.poll(() => seen.authorize.length, { timeout: 120_000 }).toBeGreaterThan(0)
await pkce(seen, base)
})
test('/signup asks hanzo.id for the sign-up form, as the same client', async ({ page }) => {
const seen = await issuer(page)
const base = new URL(test.info().project.use.baseURL ?? 'http://localhost:8084').origin
await go(page, '/signup')
await expect.poll(() => seen.authorize.length, { timeout: 120_000 }).toBeGreaterThan(0)
const url = await pkce(seen, base)
expect(url.searchParams.get('signup'), 'hanzo.id opens its sign-up form on this hint').toBe('true')
})
test('the plan starts a checkout for the plan it names', async ({ page }) => {
await offsite(page, 'hanzo.ai/pay')
await go(page, '/pricing')
const start = link(page, /^Start — \$/)
await expect(start).toBeVisible({ timeout: 120_000 })
const landed = await follow(page, start)
expect(`${landed.host}${landed.pathname}`, 'checkout is hanzo.ai/pay').toMatch(/^hanzo\.ai\/pay(\/|$)/)
expect(landed.searchParams.get('plan'), 'the checkout opens on the plan the card sold').toBeTruthy()
expect(landed.searchParams.get('returnUrl'), 'a paid buyer comes back to the product').toBe('https://hanzo.ai/chat')
})
test('How budgets work opens the docs page that explains them', async ({ page }) => {
await offsite(page, 'docs.hanzo.ai')
await go(page, '/pricing')
const how = link(page, /^How budgets work$/)
await expect(how).toHaveAttribute('href', 'https://docs.hanzo.ai/docs/credits', { timeout: 120_000 })
const [popup] = await Promise.all([page.waitForEvent('popup'), how.click()])
await popup.waitForLoadState('domcontentloaded')
expect(place(popup.url())).toBe('https://docs.hanzo.ai/docs/credits')
})
test('every control under the rate card is a link to the console', async ({ page }) => {
await go(page, '/pricing')
const build = page.getByRole('main').getByText('Start building', { exact: true })
await expect(build.first()).toBeVisible({ timeout: 120_000 })
const count = await build.count()
expect(count, 'the rate card and the tools table each end in one').toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const a = build.nth(i).locator('xpath=ancestor-or-self::a[1]')
await expect(a, '"Start building" is a link, not a button that goes nowhere').toHaveAttribute(
'href',
'https://platform.hanzo.ai',
)
}
await offsite(page, 'platform.hanzo.ai')
expect((await follow(page, build.first())).host).toBe('platform.hanzo.ai')
})
/** A product card commerce does not sell has no checkout, so it opens the
* sales page. It used to open /signup, which ends at the paywall for a
* different plan at a different price. */
for (const from of ['/captable', '/sign', '/dataroom']) {
test(`no card on ${from} sends a buyer to a plan it cannot buy`, async ({ page }) => {
await go(page, from)
const sales = page.locator('main a[href="/contact-sales"]', { hasText: /contact sales/i })
await expect(sales.first()).toBeVisible({ timeout: 120_000 })
expect(await page.locator('main a[href="/signup"]').count(), 'a card that ends at /signup').toBe(0)
expect((await follow(page, sales.first())).pathname).toBe('/contact-sales')
})
}
test('/ai starts at sign-in', async ({ page }) => {
await go(page, '/ai')
const start = link(page, /Try Hanzo/)
await expect(start.first()).toHaveAttribute('href', '/login', { timeout: 120_000 })
})
test('the /analytics hero opens sign-in and the sales page', async ({ page }) => {
await go(page, '/analytics')
const start = link(page, /^Try Hanzo$/)
await expect(start).toBeVisible({ timeout: 120_000 })
await expect(link(page, /Request Demo/)).toHaveAttribute('href', '/contact-sales')
expect((await follow(page, start)).pathname).toBe('/login')
})
for (const from of ['/pricing', '/enterprise', '/security', '/solutions/healthcare']) {
test(`Contact sales on ${from} opens the sales page`, async ({ page }) => {
await go(page, from)
const sales = link(page, /^\s*contact sales\s*$/i)
await expect(sales).toBeVisible({ timeout: 120_000 })
const landed = await follow(page, sales)
expect(landed.pathname).toBe('/contact-sales')
})
}
test('the sales page files a lead, then books a real event type with the details filled in', async ({ page }) => {
await offsite(page, 'cal.com')
await page.route('**/v1/marketing/leads', (route) =>
route.fulfill({ status: 201, contentType: 'application/json', body: '{"lead":"lead_000000000000000000000000","stage":"new"}' }),
)
await go(page, '/contact-sales')
await expect(page.getByText('[email protected]', { exact: true })).toBeVisible({ timeout: 120_000 })
await page.getByLabel('Work email *').fill('[email protected]')
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByLabel('Full name *').fill('Ada Lovelace')
await page.getByLabel('Company *', { exact: true }).fill('Analytical')
await page.getByLabel('Company size *').selectOption('50-249')
await page.getByLabel('Country *').selectOption('United Kingdom')
const landed = await follow(page, page.getByRole('button', { name: 'Book a call' }))
expect(`${landed.origin}${landed.pathname}`).toBe('https://cal.com/hanzo/30min')
expect(landed.searchParams.get('email')).toBe('[email protected]')
expect(landed.searchParams.get('name')).toBe('Ada Lovelace')
})
test('/contact books a call and shows its address as text, and asks for nothing to post', async ({ page }) => {
await offsite(page, 'cal.com')
await go(page, '/contact')
await expect(page.getByText('[email protected]', { exact: true }).first()).toBeVisible({ timeout: 120_000 })
await expect(page.getByRole('main').locator('form'), 'no anonymous route takes a message').toHaveCount(0)
const book = page.getByRole('link', { name: 'Book a 30-minute intro' })
await expect(book).toHaveAttribute('href', 'https://cal.com/hanzo/30min')
const [popup] = await Promise.all([page.waitForEvent('popup'), book.click()])
await popup.waitForLoadState('domcontentloaded')
expect(place(popup.url())).toBe('https://cal.com/hanzo/30min')
})
test('Talk to us on /dns opens the sales page', async ({ page }) => {
await go(page, '/dns')
const talk = link(page, /^\s*talk to us\s*$/i)
await expect(talk).toBeVisible({ timeout: 120_000 })
expect((await follow(page, talk)).pathname).toBe('/contact-sales')
})
test('the footer reaches docs, status, contact and the legal documents', async ({ page }) => {
await go(page, '/pricing')
const foot = page.getByRole('contentinfo')
await expect(foot).toBeVisible({ timeout: 120_000 })
const href = (name: string) => foot.getByRole('link', { name, exact: true }).first()
await expect(href('Documentation')).toHaveAttribute('href', 'https://docs.hanzo.ai')
await expect(href('System Status')).toHaveAttribute('href', 'https://status.hanzo.ai')
await expect(href('Contact')).toHaveAttribute('href', 'https://hanzo.ai/contact')
await expect(href('Privacy')).toHaveAttribute('href', 'https://hanzo.ai/privacy')
await expect(href('Terms')).toHaveAttribute('href', 'https://hanzo.ai/terms')
await offsite(page, 'docs.hanzo.ai')
expect((await follow(page, href('Documentation'))).host).toBe('docs.hanzo.ai')
})
test('/status and /docs forward to the hosts that own them', async ({ page }) => {
await offsite(page, 'status.hanzo.ai', 'docs.hanzo.ai')
// The request, not the arrival: the shell forwards as soon as it runs, and
// a dev server's own reload can abort the landing after the browser asked.
for (const [path, host] of [['/status', 'status.hanzo.ai'], ['/docs', 'docs.hanzo.ai']]) {
const asked = page.waitForRequest((r) => r.isNavigationRequest() && new URL(r.url()).host === host, {
timeout: 150_000,
})
await page.goto(path, { waitUntil: 'commit', timeout: 150_000 })
await asked
}
})
test('every document a buyer is asked to accept is linked from /legal and served', async ({ page }) => {
await go(page, '/legal')
const docs: Record<string, RegExp> = {
terms: /Terms of Service/,
privacy: /Privacy Policy/,
'acceptable-use': /Acceptable Use Policy/,
dpa: /Data Processing Addendum/,
subprocessors: /Subprocessor Register/,
}
for (const [slug, title] of Object.entries(docs)) {
const link = page.getByRole('main').locator(`a[href="/legal/${slug}"]`).first()
await expect(link, `/legal links /legal/${slug}`).toHaveText(title, { timeout: 120_000 })
}
for (const [slug, title] of Object.entries(docs)) {
const res = await go(page, `/legal/${slug}`)
expect(res?.status(), `/legal/${slug}`).toBe(200)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
}
})
/** hanzo.id's sign-up footer links https://hanzo.ai/terms. It asks for no
* agreement and records none (identity stage). The Terms name the documents
* they incorporate in their front matter, and a reader reaches each one from
* there. */
test('the Terms link every document they incorporate', async ({ page }) => {
const front = readFileSync('content/legal/terms.md', 'utf8').match(/^incorporates:\s*\[([^\]]*)\]/m)
const slugs = (front?.[1] ?? '').split(',').map((s) => s.trim()).filter(Boolean)
expect(slugs.length, 'terms.md names what it incorporates').toBeGreaterThan(0)
await go(page, '/terms')
await expect(page.getByRole('heading', { level: 1 })).toHaveText(/Terms of Service/, { timeout: 120_000 })
for (const slug of slugs) {
await expect(page.getByRole('main').locator(`a[href="/legal/${slug}"]`).first(), `/terms links /legal/${slug}`).toBeAttached()
}
})