mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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
257 lines
12 KiB
TypeScript
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()
|
|
}
|
|
})
|