mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
310 lines
19 KiB
TypeScript
310 lines
19 KiB
TypeScript
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'])
|
||
})
|
||
|
||
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 page.waitForURL((u) => u.pathname === '/onboarding', { timeout: 30_000 })
|
||
await expect(page.getByRole('heading', { name: 'Create your account' })).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, '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 picker, which asks for terms still owed and opens the checkout.
|
||
await expect.poll(async () => new URL((await pro.getAttribute('href'))!, base).pathname, { timeout: 20_000, message: 'signed in: through the picker' }).toBe('/onboarding')
|
||
const next = new URL(new URL((await pro.getAttribute('href'))!, base).searchParams.get('next')!, base)
|
||
expect([next.pathname, next.searchParams.get('plan')]).toEqual(['/pay/confirm/card', 'dev'])
|
||
})
|
||
|
||
test('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 }])
|
||
})
|
||
|
||
test('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' })
|
||
})
|
||
|
||
test('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' })
|
||
})
|
||
|
||
// Try Hanzo is the sign-up's own page (components/home/nav-data `TRY`, 923b8b59),
|
||
// from the landing as from everywhere: the address bar says /signup and the card
|
||
// there makes the account.
|
||
for (const width of [1440, 390]) {
|
||
test(`at ${width}px Home’s Try Hanzo opens /signup, the card that makes the account`, 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"]')
|
||
// Hydrated: the panel has put focus on its address field.
|
||
await expect(panel.getByLabel('Email')).toBeFocused({ timeout: 20_000 })
|
||
const bar = page.getByRole('banner')
|
||
// The bar hydrates on its own: a press before it is lost, so the toggle is
|
||
// pressed until the sheet opens, as chrome.spec opens it.
|
||
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 tryHanzo.click()
|
||
await page.waitForURL((u) => u.pathname === '/signup', { timeout: 20_000 })
|
||
const signup = page.getByRole('region', { name: 'Sign up' })
|
||
await expect(signup.getByRole('button', { name: /Continue with Google$/ })).toBeVisible({ timeout: 20_000 })
|
||
await expect(signup.getByLabel('Email')).toBeVisible()
|
||
await expect(page.getByRole('dialog', { name: 'Menu' })).toHaveCount(0)
|
||
})
|
||
}
|