Files
hanzo.ai/e2e/gates/onboarding.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

355 lines
19 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 { expect, test, type Page } from '@playwright/test'
import { serveExport } from './export'
import { stream } from './heard'
import { signIn } from '../session'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
/**
* A NEW ACCOUNT IS ASKED ONE QUESTION; A RETURNING ONE IS ASKED NOTHING.
*
* The callback sends each sign-in to /onboarding (@hanzo/app/lib/destination
* `picker`), which reads the account from IAM (components/onboarding/flow.tsx):
*
* - made more than an hour ago, or answered here before, or paid for: on to
* where the sign-in was headed, nothing drawn and billing never asked;
* - made a moment ago with no terms on record: the terms first;
* - then one question, Personal or Business, two cards of what each side's
* plans include, each a link to pay's plans (/pay/cart) for the
* organization worked in, coming back to the app. The answer is kept on
* the IAM user.
*
* No plan card is drawn here: the plans are pay's. IAM and billing are answered
* by the spec; what is measured is the page and the address it leaves for.
*/
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
const ACCEPTED = { terms: TERMS_VERSION, aup: AUP_VERSION }
const NOW = () => new Date(Date.now() - 60_000).toISOString()
const LONG_AGO = '2024-01-01T00:00:00Z'
interface Account {
/** IAM's createdTime. */
made?: string
/** IAM refuses the preferences read. */
down?: boolean
/** What IAM keeps: `terms`, `onboarding`. */
prefs?: Record<string, unknown>
/** What billing answers for every organization. */
tier?: string
}
interface Heard {
/** Every write the page sent to IAM, billing or the accounts, as `METHOD path body`. */
writes: string[]
/** The organizations billing was asked about. */
tiers: string[]
}
/** The platform as a spec wants it. Returns what the page asked and wrote. */
async function platform(page: Page, { made = NOW(), prefs = { terms: ACCEPTED }, tier = 'free', down = false }: Account = {}): Promise<Heard> {
const heard: Heard = { writes: [], tiers: [] }
const kept: Record<string, unknown> = { ...prefs }
await page.route(
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/oauth/'),
(route) => {
const req = route.request()
const path = new URL(req.url()).pathname
const body = req.postData() ?? ''
if (path === '/v1/iam/preferences' && down) return route.fulfill({ status: 503, json: { status: 'error', msg: 'down' } })
if (path === '/v1/iam/preferences') {
const patch = JSON.parse(body || '{}') as Record<string, unknown>
if (Object.keys(patch).length) {
heard.writes.push(`POST ${path} ${body}`)
Object.assign(kept, patch)
}
return route.fulfill({ json: { status: 'ok', data: kept } })
}
if (req.method() !== 'GET' && /^\/v1\/(iam|billing|account)\//.test(path)) heard.writes.push(`${req.method()} ${path}${body ? ` ${body}` : ''}`)
if (path === '/v1/iam/terms') return route.fulfill({ json: { status: 'ok', data: ACCEPTED } })
if (path === '/v1/iam/account') return route.fulfill({ json: { status: 'ok', data: { name: 'gate', displayName: '', email: '[email protected]', createdTime: made } } })
if (path === '/v1/billing/tier') {
heard.tiers.push(req.headers()['x-org-id'] ?? '-')
return route.fulfill({
json: {
user: 'gate',
plan: tier === 'free' ? '' : 'max-20x',
tier: { name: tier, displayName: tier, dailyCreditsCents: 0, maxAgents: 1, unlimitedAgents: false },
balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 },
},
})
}
if (path === '/v1/billing/balance') return route.fulfill({ json: { balance: 0, holds: 0, available: 0 } })
if (path.endsWith('/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
return route.fulfill({ json: {} })
},
)
return heard
}
/** The question, as a reader finds it. */
const question = (page: Page) => page.getByRole('heading', { name: 'How will you use Hanzo?' })
const personal = (page: Page) => page.getByRole('link', { name: /^Personal/ })
const business = (page: Page) => page.getByRole('link', { name: /^Business/ })
/** pay's plans, once the page has left for them: the query, read as one record. */
async function plans(page: Page): Promise<Record<string, string>> {
await page.waitForURL((u) => u.pathname === '/pay/cart', { timeout: 15_000 })
return Object.fromEntries(new URL(page.url()).searchParams)
}
/** Whether a page in this tab ever drew the question: reported the moment it appears. */
async function watch(page: Page): Promise<() => boolean> {
let drawn = false
await page.exposeFunction('questionDrawn', () => {
drawn = true
})
await page.addInitScript(() => {
new MutationObserver(() => {
if (document.body?.innerText.includes('How will you use Hanzo?')) (window as unknown as { questionDrawn: () => void }).questionDrawn()
}).observe(document, { childList: true, subtree: true, characterData: true })
})
return () => drawn
}
test('a signed-in account opening /chat lands on /chat, not /onboarding', async ({ page }) => {
await signIn(page)
await platform(page, { tier: 'dev' })
const asked: string[] = []
page.on('framenavigated', (f) => {
if (f === page.mainFrame()) asked.push(new URL(f.url()).pathname)
})
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/chat`)
await expect(page.locator('main textarea').first()).toBeVisible({ timeout: 30_000 })
await page.waitForTimeout(3000)
expect(new URL(page.url()).pathname).toBe('/chat')
expect(asked.filter((p) => p.startsWith('/onboarding')), 'never sent to /onboarding').toEqual([])
})
for (const width of [1440, 390]) {
test(`at ${width}px a new account is asked one question, with pay's plans behind each answer`, async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
await platform(page)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}/onboarding?next=%2Fchat`)
await expect(question(page)).toBeVisible({ timeout: 30_000 })
// Each answer opens pay's plans on its own tab (b2de8afa).
const cart = (tab: string) => `/pay/cart?${new URLSearchParams({ org: 'gate-org', returnUrl: `${base}/chat` })}&tab=${tab}`
await expect(personal(page)).toHaveAttribute('href', cart('individual'))
await expect(business(page)).toHaveAttribute('href', cart('team'))
await expect(page.getByRole('button', { name: 'Continue with Free' })).toHaveCount(0)
// Business plans hold Hanzo Team, and only they say so.
await expect(business(page)).toContainText('Hanzo Team')
await expect(personal(page)).not.toContainText('Hanzo Team')
// No plan is drawn here: no price, no plan card, no eyebrow.
await expect(page.getByText(/\$\d/)).toHaveCount(0)
await expect(page.getByRole('region', { name: /^(Free|Pro|Max|Team|Enterprise)$/ })).toHaveCount(0)
// The title at 32px (26px on a phone), and nothing wider than the window.
const title = await question(page).evaluate((el) => getComputedStyle(el).fontSize)
expect(title).toBe(width < 640 ? '26px' : '32px')
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
})
}
// Each answer opens pay's cart on its tab (b2de8afa); what this page owns is the answer it keeps and counts.
for (const [choice, use, tab] of [
['Personal', 'personal', 'individual'],
['Business', 'team', 'team'],
] as const) {
test(`${choice} keeps and counts the answer, and opens pay's plans for the organization worked in, never counted as a checkout begun`, async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page)
const events = await stream(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/onboarding?next=%2Fchat`)
await page.getByRole('link', { name: new RegExp(`^${choice}`) }).click({ timeout: 30_000 })
expect(await plans(page)).toEqual({ org: 'gate-org', returnUrl: `${base}/chat`, tab })
expect(heard.writes, 'the answer, and nothing bought or made').toEqual([`POST /v1/iam/preferences ${JSON.stringify({ onboarding: { use } })}`])
await expect.poll(() => events.filter((e) => e.event === 'onboarding_answered').map((e) => e.properties?.use), { message: 'the answer, counted' }).toEqual([use])
})
}
test('the answers are links the site’s checkout count passes over (data-ga), so opening the plans is no checkout begun', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
await platform(page)
await page.goto(`${base}/onboarding?next=%2Fchat`)
await expect(personal(page)).toHaveAttribute('data-ga', '', { timeout: 30_000 })
await expect(business(page)).toHaveAttribute('data-ga', '')
})
test('the campaign this visit landed with rides on to pay’s plans after the sign-in took it out of the address, and an old first touch never does', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
await platform(page)
await page.addInitScript(() => localStorage.setItem('hz_first_touch', JSON.stringify({ utm: { source: 'old-newsletter', campaign: 'spring' } })))
await page.goto(`${base}/pricing?${new URLSearchParams({ utm_source: 'hanzo', utm_medium: 'email', utm_campaign: 'again' })}`)
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('hz_campaign')), { timeout: 30_000 }).toBe('utm_source=hanzo&utm_medium=email&utm_campaign=again')
await page.goto(`${base}/onboarding?next=%2Fchat`)
await page.getByRole('link', { name: /^Personal/ }).click({ timeout: 30_000 })
expect(await plans(page)).toEqual({ org: 'gate-org', utm_source: 'hanzo', utm_medium: 'email', utm_campaign: 'again', returnUrl: `${base}/chat`, tab: 'individual' })
})
test('a visit that landed with no campaign hands pay none, whatever an old first touch says', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
await platform(page)
await page.addInitScript(() => localStorage.setItem('hz_first_touch', JSON.stringify({ utm: { source: 'old-newsletter', campaign: 'spring' } })))
await page.goto(`${base}/onboarding?next=%2Fchat`)
await page.getByRole('link', { name: /^Personal/ }).click({ timeout: 30_000 })
expect(await plans(page)).toEqual({ org: 'gate-org', returnUrl: `${base}/chat`, tab: 'individual' })
})
for (const [who, next] of [
['a member invited into an organization, headed for its invitation', '/join?invite=abc'],
['a member of the organization worked in', '/chat'],
] as const) {
test(`${who} goes straight on, asked nothing`, async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: next === '/chat' ? 'member' : 'owner' }] })
const heard = await platform(page)
const drawn = await watch(page)
await page.goto(`${base}/onboarding?next=${encodeURIComponent(next)}`)
await page.waitForURL((u) => u.pathname === new URL(next, base).pathname, { timeout: 30_000 })
expect(drawn()).toBe(false)
expect(heard.writes).toEqual([])
})
}
test('with IAM refusing the preferences read, a new account still agrees to the terms, and no answer is written over what IAM keeps', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page, { down: true })
await page.goto(`${base}/onboarding?next=%2Fchat`)
await page.getByRole('checkbox', { name: /^I agree to Hanzo's Terms/ }).click({ timeout: 30_000 })
await page.getByRole('button', { name: 'Create account' }).click()
await expect(question(page)).toBeVisible({ timeout: 15_000 })
await personal(page).click()
await expect(page).toHaveURL(/\/pay\/cart/, { timeout: 15_000 })
expect(heard.writes, 'the terms recorded; no answer written blind').toEqual([`PUT /v1/iam/terms ${JSON.stringify(ACCEPTED)}`])
})
test('Free chosen on /pricing, with the terms on record, is kept as the answer beside what IAM holds', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page, { prefs: { terms: ACCEPTED, onboarding: { notice: true } } })
await page.addInitScript(() => {
if (!sessionStorage.getItem('gate.free')) {
sessionStorage.setItem('gate.free', '1')
sessionStorage.setItem('signin.free', '1')
}
})
await page.goto(`${base}/onboarding?next=%2F`)
await expect(page).toHaveURL(/\/chat$/, { timeout: 30_000 })
expect(heard.writes).toEqual([`POST /v1/iam/preferences ${JSON.stringify({ onboarding: { notice: true, plan: 'free' } })}`])
})
test('a next that resolves to this page goes into the app, never back here', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
await platform(page, { made: LONG_AGO })
await page.goto(`${base}/onboarding?next=${encodeURIComponent('/./onboarding')}`)
await expect(page).toHaveURL(/\/chat$/, { timeout: 30_000 })
})
for (const [who, account] of [
['an account made long ago, on Free', { made: LONG_AGO }],
['a new account that answered before', { prefs: { terms: ACCEPTED, onboarding: { use: 'personal' } } }],
] as const) {
test(`${who} goes straight on, asked nothing and billing never read`, async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page, account)
const drawn = await watch(page)
await page.setViewportSize({ width: 1440, height: 900 })
// Headed for a page that reads no billing of its own: /pricing reads the reader's tier (eab0150c).
await page.goto(`${base}/onboarding?next=%2Fabout`)
await expect(page).toHaveURL(/\/about$/, { timeout: 30_000 })
expect(drawn(), 'the question was never drawn').toBe(false)
expect(heard.tiers, 'billing is not asked').toEqual([])
expect(heard.writes, 'nothing written').toEqual([])
})
}
test('a new account its organization already pays for goes straight on', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page, { tier: 'pro' })
const drawn = await watch(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/onboarding?next=%2Fchat`)
await expect(page).toHaveURL(/\/chat$/, { timeout: 30_000 })
expect(drawn()).toBe(false)
expect(heard.tiers, 'the tier is read for the organization worked in').toContain('gate-org')
})
test('a new account a provider made agrees to the terms, recorded by IAM, then gets the question', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page, { prefs: {} })
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/onboarding?next=%2Fchat`)
await expect(page.getByRole('heading', { name: 'Create your account' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByText('[email protected]', { exact: true })).toBeVisible()
const create = page.getByRole('button', { name: 'Create account' })
await expect(create, 'the terms come first').toBeDisabled()
await page.getByRole('checkbox', { name: /^I agree to Hanzo's Terms of Service and Acceptable Use Policy/ }).click()
await create.click()
await expect(question(page)).toBeVisible({ timeout: 15_000 })
expect(heard.writes).toEqual([`PUT /v1/iam/terms ${JSON.stringify(ACCEPTED)}`])
})
test('Free chosen on /pricing goes into the app after the terms, without the question', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page, { prefs: {} })
const drawn = await watch(page)
await page.addInitScript(() => {
if (!sessionStorage.getItem('gate.free')) {
sessionStorage.setItem('gate.free', '1')
sessionStorage.setItem('signin.free', '1')
}
})
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/onboarding?next=%2F`)
await page.getByRole('checkbox', { name: /^I agree to Hanzo's Terms/ }).click({ timeout: 30_000 })
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page).toHaveURL(/\/chat$/, { timeout: 15_000 })
expect(drawn()).toBe(false)
expect(heard.writes).toEqual([`PUT /v1/iam/terms ${JSON.stringify(ACCEPTED)}`, `POST /v1/iam/preferences ${JSON.stringify({ onboarding: { plan: 'free' } })}`])
})
test('a checkout chosen before signing in is opened as it was asked for, billed to the organization worked in', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/onboarding?next=${encodeURIComponent('/pay/confirm/card?plan=max-20x&interval=year&coupon=50OFF&utm_campaign=again')}`)
await page.waitForURL((u) => u.pathname === '/pay/confirm/card', { timeout: 30_000 })
expect(Object.fromEntries(new URL(page.url()).searchParams)).toEqual({ plan: 'max-20x', interval: 'year', coupon: '50OFF', utm_campaign: 'again', org: 'gate-org' })
expect(heard.tiers, 'billing is not asked: the checkout is already chosen').toEqual([])
})
test('a team chosen on /pricing goes straight to its checkout, billed to the organization worked in', async ({ page }) => {
await signIn(page, { orgs: [{ org: 'gate-org', role: 'owner' }] })
const heard = await platform(page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/onboarding?next=${encodeURIComponent('/pay/confirm/card?plan=team&quantity=2')}`)
await page.waitForURL((u) => u.pathname === '/pay/confirm/card', { timeout: 30_000 })
expect(Object.fromEntries(new URL(page.url()).searchParams)).toEqual({ plan: 'team', quantity: '2', org: 'gate-org' })
expect(heard.writes.filter((w) => w.startsWith('POST /v1/account/orgs')), 'no organization is made on the way').toEqual([])
})
// The app stands behind the organization gate (app/_web.tsx, @hanzo/app
// `Orgs`): an account in no organization makes one first, and is drawn none of
// the app until it has. A personal one on Free goes straight in (orgs.spec.ts).
test('an account with no organization is asked to make one before the app', async ({ page }) => {
await signIn(page, { orgs: [] })
await platform(page, { prefs: {} })
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
const ask = page.getByRole('region', { name: 'Organizations' })
await expect(ask).toBeVisible({ timeout: 30_000 })
await expect(ask.getByText('You do not belong to one yet.')).toBeVisible()
await expect(ask.getByRole('button', { name: 'New organization' })).toBeVisible()
expect(new URL(page.url()).pathname).toBe('/')
await expect(page.getByRole('navigation', { name: 'Hanzo' }), 'none of the app').toHaveCount(0)
await expect(page.locator('textarea'), 'no composer').toHaveCount(0)
})