mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
355 lines
19 KiB
TypeScript
355 lines
19 KiB
TypeScript
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)
|
||
})
|