Files
zeekay 9eb3d07b65 sign-in: one page to the code, the API warm, no asks a member never sees
Try Hanzo opens /signup directly instead of /chat bouncing a stranger
there, which cost a whole page load on a phone (13 s throttled).

/login no longer sends a new address to /signup: both pages send the
link at once and make the account once the code proves the address, so
the person stays on the page they chose.

/login, /signup and /auth/callback preconnect to api.hanzo.ai from the
head, so the methods read, the token exchange and the discovery a
provider press needs start on a warm connection.

A member at /login is marked before first paint (the app's BOOT under
data-member), the form stays off screen while the page moves them on,
and the panel asks IAM for no sign-in methods it would never draw.

The callback starts the bind and the standing read together instead of
one after the other.
2026-10-30 07:36:48 -07:00

303 lines
18 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 Locator, type Page } from '@playwright/test'
import { serveExport } from './export'
import { heard, stream } from './heard'
import { issuer, jwt, type Options, type Seen } from './issuer'
import { PLANS, serve } from './api'
import { signIn } from '../session'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
/**
* A CHECKOUT'S SIGN-IN KEEPS THE CHECKOUT, AND GOES BACK TO IT.
*
* pay sends a buyer with no session to sign in with the checkout as `?next=`
* (hanzo-inc/pay lib/auth `login`). A campaign's checkout carries its plan, a
* coupon and the campaign's tags. The sign-in page is that checkout's first
* step (components/auth/page.tsx, lib/auth/sale): /login stays the sign-in, so
* a returning customer is one step from the checkout, the offer is said across
* the top in pay's words from commerce's quote, and the controls are pay's 56px
* at 16px. Every way in — a provider through IAM's authorize, a
* new address proven by a code, a known one by its password — lands on the
* checkout exactly as it was asked for (components/onboarding/flow.tsx), past
* the terms where IAM says they are owed.
*
* IAM and commerce are answered here (./issuer, the quote below); what is
* measured is the page, and the address the browser ends on.
*/
/** The checkout a campaign's buyer was on: plan, seats, coupon and the campaign. */
const CHECKOUT =
'/pay/confirm/card?plan=dev&quantity=1&coupon=50OFFFOUNDER&utm_source=hanzo&utm_medium=email&utm_campaign=again-welcome&utm_content=offer'
const NEXT = encodeURIComponent(CHECKOUT)
const TOKEN = jwt({ sub: 'ada', name: 'ada', email: '[email protected]', orgs: [{ org: 'ada', role: 'owner' }] })
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
/** The catalog as commerce quotes it for `coupon`: half off each paid plan's first month. */
function quote(coupon: string | null) {
if (!coupon) return PLANS
return PLANS.map((r) =>
r.price > 0 && !r.contactSales && r.category === 'personal'
? { ...r, couponCode: coupon, couponPercent: 50, couponFirstCents: r.price / 2 }
: { ...r, couponError: 'not_applicable' },
)
}
interface World extends Seen {
/** What IAM was told the person accepted (PUT /v1/iam/terms). */
accepted: unknown[]
}
/**
* The world, answered: this origin and IAM (./issuer), commerce's quote, and
* IAM's word on the account's terms — accepted, or owed by an account made a
* moment ago — wherever the page asks them (`api()`). Everything else off this
* machine fails.
*/
async function world(page: Page, { owed = false, ...options }: Pick<Options, 'reply' | 'authorize'> & { owed?: boolean } = {}): Promise<World> {
await page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
const seen = await issuer(page, { site: base, token: TOKEN, ...options })
const accepted: unknown[] = []
await page.route(
(u) => ['/v1/billing/plans', '/v1/iam/preferences', '/v1/iam/account', '/v1/iam/terms'].includes(u.pathname),
(route) => {
const req = route.request()
const url = new URL(req.url())
const headers = { 'access-control-allow-origin': req.headers().origin ?? base, 'access-control-allow-credentials': 'true', 'access-control-allow-headers': 'content-type, authorization, x-org-id' }
if (req.method() === 'OPTIONS') return route.fulfill({ status: 204, headers })
if (url.pathname === '/v1/billing/plans') return route.fulfill({ headers, json: quote(url.searchParams.get('coupon')) })
if (url.pathname === '/v1/iam/terms') {
accepted.push(JSON.parse(req.postData() || '{}'))
return route.fulfill({ headers, json: { terms: TERMS_VERSION, aup: AUP_VERSION } })
}
if (url.pathname === '/v1/iam/preferences') return route.fulfill({ headers, json: owed ? {} : { terms: { terms: TERMS_VERSION, aup: AUP_VERSION } } })
return route.fulfill({ headers, json: { name: 'ada', displayName: '', email: '[email protected]', createdTime: owed ? new Date().toISOString() : '2024-01-01T00:00:00Z' } })
},
)
return Object.assign(seen, { accepted })
}
/** The sign-in card. */
const card = (page: Page) => page.getByRole('region', { name: /^(Log in|Sign up)$/ })
/** The checkout the browser landed on, as a record of its query, once it is there. */
async function landed(page: Page): Promise<Record<string, string>> {
await page.waitForURL((u) => u.pathname === '/pay/confirm/card', { timeout: 30_000 })
return Object.fromEntries(new URL(page.url()).searchParams)
}
/** The checkout as it was asked for, billed to the one organization the account works in (`TOKEN`). */
const ASKED = { ...Object.fromEntries(new URL(CHECKOUT, 'https://hanzo.ai').searchParams), org: 'ada' }
/** A control's height and type, as the browser lays them out. */
async function measure(control: Locator): Promise<{ height: number; font: string }> {
return control.evaluate((el) => ({ height: Math.round(el.getBoundingClientRect().height), font: getComputedStyle(el).fontSize }))
}
/** Where the sign-in is written down for the callback (lib/auth/destination). */
const destination = (page: Page) => page.evaluate(() => sessionStorage.getItem('signin.destination'))
for (const width of [1440, 390]) {
test(`at ${width}px /login from a coupon checkout stays the sign-in, with its offer, at pay's measure`, async ({ page }) => {
await world(page)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}/login?next=${NEXT}`)
await expect(page.getByRole('status').filter({ hasText: '50% off your first month of Pro' }), 'the offer, in pay’s words').toBeVisible({ timeout: 20_000 })
expect(new URL(page.url()).pathname, 'the route stands').toBe('/login')
expect(new URL(page.url()).searchParams.get('next'), 'the checkout rides on, whole').toBe(CHECKOUT)
await expect(page.locator('#hero-title')).toHaveText('Sign in to Hanzo AI')
await expect(page.locator('[data-slot="hero-demo"]'), 'no demo').toHaveCount(0)
await expect(page.getByText('Get Started', { exact: true }), 'no eyebrow').toHaveCount(0)
expect(await destination(page), 'written down for the callback, coupon and campaign in it').toBe(CHECKOUT)
for (const control of [
card(page).getByRole('button', { name: /Continue with Google$/ }),
card(page).getByRole('button', { name: /Continue with GitHub$/ }),
card(page).getByRole('button', { name: 'Continue with email' }),
card(page).getByLabel('Email'),
])
await expect.poll(() => measure(control), { message: 'pay’s 56px control at 16px' }).toEqual({ height: 56, font: '16px' })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no sideways scroll').toBe(true)
})
}
test('before its script runs, a checkout’s sign-in holds the landing’s hero off the screen, and a plain one does not', async ({ page }) => {
await world(page)
// No script past the page's own: what paints before hydration.
await page.route((u) => u.pathname.startsWith('/_next/static/') && u.pathname.endsWith('.js'), (route) => route.abort())
const hero = page.locator('[data-slot="hero"]')
await page.goto(`${base}/signup?next=${NEXT}`)
await expect(hero).toHaveCSS('visibility', 'hidden')
await page.goto(`${base}/signup?next=%2Fchat`)
await expect(hero).toHaveCSS('visibility', 'visible')
})
test('a provider from the checkout’s sign-up is one hop through IAM’s authorize, and lands on the checkout as it was asked for', async ({ page }) => {
const seen = await world(page, { authorize: (url) => `${base}/auth/callback?code=c0de&state=${url.searchParams.get('state')}` })
const events = await stream(page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/signup?next=${NEXT}`)
await card(page).getByRole('button', { name: /Continue with Google$/ }).click()
expect(await landed(page), 'plan, coupon and campaign, exactly').toEqual(ASKED)
expect(seen.authorize, 'one authorize').toHaveLength(1)
const q = seen.authorize[0].searchParams
expect(seen.authorize[0].origin, 'on this site').toBe(base)
expect(q.get('provider')).toBe('provider-google')
expect(q.get('signup'), 'asked as an account to make').toBe('true')
expect(q.get('client_id')).toBe('hanzo-app')
expect(q.get('redirect_uri')).toBe(`${base}/auth/callback`)
expect(q.get('state'), 'the CSRF binding').toBeTruthy()
expect(await heard(events, 'signup_submitted')).toMatchObject({ method: 'google' })
})
test('a new address from the checkout’s sign-up is proven by the link’s code, made, and lands on the checkout as it was asked for', async ({ page }) => {
const seen = await world(page, { reply: { '/v1/iam/login': (body) => ({ status: 'ok', data: body.create ? 'c0de' : 'SignupRequired' }) } })
await page.goto(`${base}/signup?next=${NEXT}`)
await card(page).getByLabel('Email').fill('[email protected]')
// The link at once, no password asked: the account the terms make has none.
await card(page).getByRole('button', { name: 'Continue with email' }).click()
await card(page).getByRole('button', { name: 'Enter verification code' }).click()
await card(page).getByRole('textbox', { name: 'Verification code' }).fill('123456')
await card(page).getByRole('button', { name: 'Verify email address' }).click()
await page.getByRole('checkbox', { name: /^I agree to Hanzo's Terms of Service and Acceptable Use Policy/ }).click()
await page.getByRole('button', { name: 'Create account' }).click()
expect(await landed(page)).toEqual(ASKED)
expect(seen.posted.map((p) => p.path)).toEqual(['/v1/iam/verification-codes', '/v1/iam/login', '/v1/iam/login'])
expect(JSON.parse(seen.posted[2].body), 'the terms, accepted with the account').toMatchObject({ create: true, terms: TERMS_VERSION, aup: AUP_VERSION })
expect(seen.authorize, 'no authorize page').toEqual([])
})
test('an account that exists signs in from the checkout’s sign-in and lands on the checkout as it was asked for', async ({ page }) => {
const seen = await world(page, { reply: { '/v1/iam/auth/identifier': { status: 'ok', data: { account: true, password: true } } } })
await page.goto(`${base}/login?next=${NEXT}`)
await card(page).getByLabel('Email').fill('[email protected]')
// An account with a password is sent its link at once, the password a press away.
await card(page).getByRole('button', { name: 'Continue with email' }).click()
await card(page).getByRole('button', { name: 'Enter password instead' }).click()
await card(page).getByRole('textbox', { name: 'Password' }).fill('correct horse')
await card(page).getByRole('button', { name: 'Continue', exact: true }).click()
expect(await landed(page)).toEqual(ASKED)
expect(seen.posted.map((p) => p.path), 'the link, then the password in its place').toEqual(['/v1/iam/verification-codes', '/v1/iam/login'])
})
// The terms are asked on the callback itself (app/(marketing)/auth/callback `Owed`), with no stop at /onboarding.
test('an account a provider made a moment ago agrees to the terms once, then lands on the checkout as it was asked for', async ({ page }) => {
const seen = await world(page, { owed: true, authorize: (url) => `${base}/auth/callback?code=c0de&state=${url.searchParams.get('state')}` })
await page.goto(`${base}/signup?next=${NEXT}`)
await card(page).getByRole('button', { name: /Continue with GitHub$/ }).click()
await expect(page.getByRole('heading', { name: 'Create your account' })).toBeVisible({ timeout: 30_000 })
expect(new URL(page.url()).pathname, 'on the callback, never /onboarding').toBe('/auth/callback')
await page.getByRole('checkbox', { name: /^I agree to Hanzo's Terms of Service and Acceptable Use Policy/ }).click()
await page.getByRole('button', { name: 'Create account' }).click()
expect(await landed(page)).toEqual(ASKED)
expect(seen.accepted, 'IAM recorded the versions').toEqual([{ terms: TERMS_VERSION, aup: AUP_VERSION }])
})
test('a checkout with no coupon keeps the route’s mode and the checkout’s measure', async ({ page }) => {
await world(page)
const plain = '/pay/confirm/card?plan=max-5x'
await page.goto(`${base}/login?next=${encodeURIComponent(plain)}`)
await expect(page.locator('#hero-title')).toHaveText('Sign in to Hanzo AI', { timeout: 20_000 })
expect(new URL(page.url()).pathname, 'no coupon, no move').toBe('/login')
await expect(page.locator('[data-slot="offer"]')).toHaveCount(0)
await expect(page.locator('[data-slot="hero-demo"]')).toHaveCount(0)
// Written once the page has asked IAM who is here (components/auth/panel `Gate`), after the title is drawn.
await expect.poll(() => destination(page)).toBe(plain)
})
test('a sign-in IAM refused tries again on the checkout’s own sign-in', async ({ page }) => {
await world(page)
await page.addInitScript((to) => {
if (!sessionStorage.getItem('gate.aimed')) {
sessionStorage.setItem('gate.aimed', '1')
sessionStorage.setItem('signin.destination', to)
}
}, CHECKOUT)
await page.goto(`${base}/auth/callback?error=access_denied`)
await page.getByRole('button', { name: 'Try again' }).click()
await page.waitForURL((u) => u.pathname === '/login')
expect(new URL(page.url()).searchParams.get('next')).toBe(CHECKOUT)
await expect(page.getByRole('status').filter({ hasText: '50% off your first month of Pro' }), 'the offer is said again').toBeVisible({ timeout: 20_000 })
})
test('/pricing: a paid plan is Get and its name, the sign-up carrying its checkout signed out, the checkout itself signed in', async ({ page }) => {
await serve(page)
await page.goto(`${base}/pricing`)
const pro = page.getByRole('region', { name: 'Pro', exact: true }).getByRole('link', { name: 'Get Pro', exact: true })
const out = new URL((await pro.getAttribute('href', { timeout: 20_000 }))!, base)
expect(out.pathname, 'signed out: the sign-up').toBe('/signup')
expect(new URL(out.searchParams.get('next')!, base).pathname).toBe('/pay/confirm/card')
expect(new URL(out.searchParams.get('next')!, base).searchParams.get('plan')).toBe('dev')
await expect(page.getByRole('region', { name: 'Max', exact: true }).getByRole('link', { name: 'Get Max', exact: true })).toHaveCount(1)
await signIn(page)
await page.reload()
// Signed in: the checkout itself, with no stop on the way.
await expect.poll(async () => new URL((await pro.getAttribute('href'))!, base).pathname, { timeout: 20_000, message: 'signed in: the checkout' }).toBe('/pay/confirm/card')
expect(new URL((await pro.getAttribute('href'))!, base).searchParams.get('plan')).toBe('dev')
})
// /onboarding is not shown (owner, 2026-10-29): a sign-in goes straight on (lib/auth/arrive), and the page only forwards. Its flow is kept, unrendered, and these return with it.
test.skip('Free chosen on /pricing, then a paid plan: the new account still agrees to the terms before its checkout', async ({ page }) => {
const seen = await world(page, { owed: true, authorize: (url) => `${base}/auth/callback?code=c0de&state=${url.searchParams.get('state')}` })
await serve(page)
await page.goto(`${base}/pricing`)
await page.getByRole('region', { name: 'Free', exact: true }).getByRole('link', { name: 'Try Hanzo' }).click({ timeout: 20_000 })
await page.waitForURL((u) => u.pathname === '/signup')
expect(await page.evaluate(() => sessionStorage.getItem('signin.free')), 'Free is written down for this tab').toBe('1')
await page.goto(`${base}/signup?next=${NEXT}`)
await card(page).getByRole('button', { name: /Continue with Google$/ }).click()
await page.waitForURL((u) => u.pathname === '/onboarding', { timeout: 30_000 })
await expect(page.getByRole('heading', { name: 'Create your account' }), 'the terms, not the shortcut Free takes').toBeVisible({ timeout: 30_000 })
await page.getByRole('checkbox', { name: /^I agree to Hanzo's Terms of Service and Acceptable Use Policy/ }).click()
await page.getByRole('button', { name: 'Create account' }).click()
expect(await landed(page)).toEqual(ASKED)
expect(seen.accepted).toEqual([{ terms: TERMS_VERSION, aup: AUP_VERSION }])
})
// /onboarding is not shown (owner, 2026-10-29): a sign-in goes straight on (lib/auth/arrive), and the page only forwards. Its flow is kept, unrendered, and these return with it.
test.skip('a quote commerce never answers holds the checkout no longer than a moment', async ({ page }) => {
await world(page)
await signIn(page, { orgs: [{ org: 'ada', role: 'owner' }] })
// Registered last, so it answers first: the quote hangs.
await page.route((u) => u.pathname === '/v1/billing/plans', () => {})
await page.addInitScript(() => sessionStorage.setItem('checkout.coupon', '50OFF'))
await page.goto(`${base}/onboarding?next=${encodeURIComponent('/pay/confirm/card?plan=dev')}`)
expect(await landed(page), 'the checkout as it was asked for, no coupon it was never quoted').toEqual({ plan: 'dev', org: 'ada' })
})
// /onboarding is not shown (owner, 2026-10-29): a sign-in goes straight on (lib/auth/arrive), and the page only forwards. Its flow is kept, unrendered, and these return with it.
test.skip('a coupon this tab keeps, quoted for the plan, rides on to a checkout that names none', async ({ page }) => {
await world(page)
await signIn(page, { orgs: [{ org: 'ada', role: 'owner' }] })
await page.addInitScript(() => sessionStorage.setItem('checkout.coupon', '50OFF'))
await page.goto(`${base}/onboarding?next=${encodeURIComponent('/pay/confirm/card?plan=dev&returnUrl=https%3A%2F%2Fhanzo.ai%2F')}`)
expect(await landed(page)).toEqual({ plan: 'dev', returnUrl: 'https://hanzo.ai/', coupon: '50OFF', org: 'ada' })
})
// The bar's control is Try Hanzo, which is the sign-up and goes on to the app at /chat. The hero still holds the sign-up panel.
for (const width of [1440, 390]) {
test(`at ${width}px the bar’s Try Hanzo is the sign-up, and the hero still signs up`, async ({ page }) => {
await world(page)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}/`)
const panel = page.locator('[data-landing] [data-slot="hero"] [data-slot="signin"]')
await expect(panel.getByLabel('Email')).toBeFocused({ timeout: 20_000 })
const bar = page.getByRole('banner')
if (width < 1024)
await expect(async () => {
await bar.getByRole('button', { name: 'Open menu' }).click()
await expect(page.getByRole('dialog', { name: 'Menu' })).toBeVisible({ timeout: 1000 })
}).toPass({ timeout: 15000 })
const tryHanzo = page.getByRole(width < 1024 ? 'dialog' : 'banner').getByRole('link', { name: /^Try Hanzo/ })
await expect(tryHanzo).toHaveAttribute('href', '/signup')
await expect(panel.getByLabel('Email')).toBeVisible()
})
}