mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
/pricing opens on the plans; what a metered bill is made of and every model's rate are drawn only while API is the tab on screen. Plans tells the page its tab (onTab). The gates read the rate card, Kai's card and the unreachable state on /pricing#api, and hold the sections off every other tab. Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
516 lines
29 KiB
TypeScript
516 lines
29 KiB
TypeScript
import { expect, test, type Locator, type Page } from '@playwright/test'
|
||
import { serveExport } from './export'
|
||
import { PLANS, PRICING, block, money, serve, type Row } from './api'
|
||
import { signIn } from '../session'
|
||
|
||
/**
|
||
* /PRICING STATES EACH PRICE ONCE, AND STATES THE ONE THE CLOUD CHARGES.
|
||
*
|
||
* Two surfaces on the page carried a number the code does not:
|
||
*
|
||
* A WEB CALL HAD TWO PRICES. The tool grid printed the catalog's old Web
|
||
* Search row beside the rate card's web_search — the card the ledger books by,
|
||
* at less than half the grid's figure — and never listed Web Fetch at all.
|
||
*
|
||
* A PLAN IS A SUBSCRIPTION WITH USAGE LIMITS. It mints no credit and its
|
||
* usage never draws the balance, so the FAQ says what a plan includes in
|
||
* those words and never promises credit.
|
||
*
|
||
* THE PLANS ARE THE AUTHORITY'S, AND SO IS EVERY LATER EDIT. The cards draw
|
||
* the rows GET /v1/billing/plans answers when the page opens, and the rate
|
||
* table the card GET /v1/pricing answers; the export carries neither. A price
|
||
* a SuperAdmin changes reaches the page with no build in between, and a
|
||
* backend that does not answer is said to be unreachable.
|
||
*
|
||
* Nothing here is a literal: the gate serves the page invented answers
|
||
* (e2e/gates/api.ts) and reads the expectation off the same answers.
|
||
*/
|
||
|
||
const tariff = PRICING.tariff
|
||
|
||
/** A web rate as the grid prints it: dollars, full precision. */
|
||
function dollars(name: string): string {
|
||
const r = tariff.rates.find((x) => x.name === name)
|
||
if (!r) throw new Error(`the rate card carries no ${name}`)
|
||
return `$${r.rate_micro_usd / 1e6}`
|
||
}
|
||
|
||
/** The rows the Personal tab draws: one person's plans, Free up. */
|
||
const individual = PLANS.filter((r) => r.category === 'personal' && !r.contactSales && !r.perSeat)
|
||
/**
|
||
* The cards the page draws for them: one Max card for the Max tiers, its pill
|
||
* on the cheaper tier first and the card priced and sold as that tier
|
||
* (components/pricing/Plans.tsx `grouped`), and a card for every other row.
|
||
* `plan` is the row checkout opens.
|
||
*/
|
||
const maxes = individual.filter((r) => /^max\b/i.test(r.name)).sort((a, b) => a.price - b.price)
|
||
const drawn: (Row & { plan: string })[] = [
|
||
...individual.filter((r) => !maxes.includes(r)).map((r) => ({ ...r, plan: r.slug })),
|
||
(({ slug, ...low }) => ({ ...low, slug: 'max', name: 'Max', plan: slug }))(maxes[0]),
|
||
]
|
||
/** The first personal plan that charges and is sold by the year. */
|
||
const paid = individual.find((r) => r.price > 0 && (r.annualTotal ?? 0) > 0)!
|
||
const team = PLANS.find((r) => r.category === 'team' && r.perSeat)!
|
||
|
||
/**
|
||
* The checkout a card plan's button opens, signed out: the sign-up, carrying pay's
|
||
* card step on the plan as `next` (components/pricing/Plans.tsx `bought`,
|
||
* lib/checkout).
|
||
*/
|
||
function bought(href: string | null): URL {
|
||
const to = new URL(href!, 'https://hanzo.ai')
|
||
expect(to.pathname, 'signs up first').toBe('/signup')
|
||
return new URL(to.searchParams.get('next')!, 'https://hanzo.ai')
|
||
}
|
||
|
||
/** A plan's card, by the name the catalog gives it. */
|
||
const card = (page: Page, name: string) => page.getByRole('region', { name, exact: true })
|
||
|
||
/**
|
||
* Turn a switch or a tab on: pressed again while it is still off, because a
|
||
* press that lands before hydration does nothing, and never once it is on, so a
|
||
* retry cannot turn it back off.
|
||
*/
|
||
async function check(control: Locator): Promise<void> {
|
||
await expect(async () => {
|
||
if (!(await control.isChecked())) await control.click()
|
||
await expect(control).toBeChecked({ timeout: 1000 })
|
||
}).toPass({ timeout: 20_000 })
|
||
}
|
||
|
||
test.describe('/pricing', () => {
|
||
test('a signed-in reader sees the plan billing serves them as their current plan, and a person is always offered the way to Business', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page)
|
||
await signIn(page, { orgs: [{ org: 'ada', role: 'owner' }] })
|
||
// Registered after signIn, so it answers first: the reader is on Max 20x.
|
||
await page.route('**/v1/billing/tier', (r) =>
|
||
r.fulfill({ json: { user: 'gate', plan: 'max-20x', tier: { name: 'pro', displayName: 'Pro', dailyCreditsCents: 0, maxAgents: 10, unlimitedAgents: false }, balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 } } }),
|
||
)
|
||
try {
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(server.url + '/pricing', { waitUntil: 'load' })
|
||
const max = card(page, 'Max')
|
||
await expect(max.getByText('Your current plan')).toBeVisible({ timeout: 20_000 })
|
||
await expect(max.getByRole('group', { name: 'Max usage' }).getByRole('button', { name: 'Max 20x' })).toHaveAttribute('aria-pressed', 'true')
|
||
await expect(card(page, 'Pro').getByText('Your current plan')).toHaveCount(0)
|
||
// Another tier of the same card is an upgrade, not the current plan.
|
||
await max.getByRole('group', { name: 'Max usage' }).getByRole('button', { name: 'Max 5x' }).click()
|
||
await expect(max.getByText('Your current plan')).toHaveCount(0)
|
||
// From the Personal tab, the way to Business.
|
||
await page.getByRole('radio', { name: 'Business' }).click()
|
||
await expect(page.getByRole('radio', { name: 'Business' })).toBeChecked()
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('prices a web tool at the rate card, once', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page)
|
||
try {
|
||
await page.goto(server.url + '/pricing#api', { waitUntil: 'load' })
|
||
const grid = page.locator('h2', { hasText: 'Explore pricing for tools' }).locator('xpath=..')
|
||
await expect(grid).toBeVisible()
|
||
for (const [label, rate] of [
|
||
['Web Search', 'web_search'],
|
||
['Web Fetch', 'web_fetch'],
|
||
]) {
|
||
const tile = grid.locator('h3', { hasText: label }).locator('xpath=..')
|
||
await expect(tile, `the tool grid lists no ${label}`).toHaveCount(1)
|
||
await expect(tile, `${label} is not priced at the card's ${rate}`).toContainText(dollars(rate))
|
||
}
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('says a plan includes usage limits, and never promises credit', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page)
|
||
try {
|
||
await page.goto(server.url + '/pricing', { waitUntil: 'load' })
|
||
// The plan cards are drawn once their read answers and push the questions
|
||
// down the page; a press made while they move lands on whatever moved under it.
|
||
await expect(card(page, 'Pro')).toBeVisible({ timeout: 20_000 })
|
||
// A question is a native <details>: its answer is in the document while it
|
||
// is shut, and one press opens it with or without script.
|
||
const question = page.getByText('What does a plan cost?', { exact: true })
|
||
const answer = page.getByText('Exactly the price on its card', { exact: false })
|
||
await expect(answer, 'a closed question paints no answer').toBeHidden()
|
||
await question.click()
|
||
await expect(answer, 'the answer opens').toBeVisible()
|
||
const text = ((await answer.textContent()) ?? '').replace(/\s+/g, ' ')
|
||
expect(text).toContain('Paid plans include usage on every model; top up prepaid credit anytime for more.')
|
||
expect(text, 'a plan mints no credit').not.toMatch(/of credit|credit included/i)
|
||
expect(text, 'no allowance figure').not.toMatch(/\$\s?\d[\d,.]*\s*(of|in)?\s*(AI )?usage|\b\d+\s*x\b|\b\d[\d,]*\s*(messages?|requests?|tokens?)\b/i)
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('draws every plan the authority lists, by tab and by term', async ({ page }) => {
|
||
const server = await serveExport()
|
||
const asked = await serve(page)
|
||
try {
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(server.url + '/pricing', { waitUntil: 'load' })
|
||
await expect(page.getByRole('heading', { level: 1, name: 'Pricing' })).toBeVisible()
|
||
|
||
for (const r of drawn) {
|
||
const c = card(page, r.name)
|
||
await expect(c, `no card for ${r.slug}`).toBeVisible()
|
||
// A paid card's one action is Get and the plan's name, and it signs up
|
||
// first carrying its checkout on as `next`; Free's is Try Hanzo.
|
||
const label = r.price > 0 ? `Get ${r.name}` : 'Try Hanzo'
|
||
const cta = c.getByRole('link', { name: label, exact: true })
|
||
await expect(cta, `${r.slug} reads ${label}`).toHaveCount(1)
|
||
const href = await cta.getAttribute('href')
|
||
if (r.price > 0) {
|
||
await expect(c).toContainText(money(r.price))
|
||
await expect(c).toContainText('Billed monthly')
|
||
const pay = bought(href)
|
||
expect(`${pay.pathname}?plan=${pay.searchParams.get('plan')}`, `${r.slug} goes on to checkout`).toBe(`/pay/confirm/card?plan=${r.plan}`)
|
||
} else {
|
||
await expect(c).toContainText('Free')
|
||
// Try Hanzo opens the sign-up (923b8b59).
|
||
expect(href, 'Free signs up, with no checkout').toBe('/signup')
|
||
}
|
||
}
|
||
expect(asked, 'the cards are the plan authority\'s answer').toContain('/v1/billing/plans')
|
||
// A plan the pages do not sell is not drawn, though the authority lists it.
|
||
await expect(card(page, 'DNS Pro')).toHaveCount(0)
|
||
// One Max card, not one per tier.
|
||
for (const m of maxes) await expect(card(page, m.name), `a card for ${m.slug}`).toHaveCount(0)
|
||
// Its pill picks the tier, each segment the multiple the tier's own name
|
||
// states; the card's price, line, features and checkout are the picked
|
||
// tier's, and the pill sits on the name's row inside the card.
|
||
const max = card(page, 'Max')
|
||
const tier = max.getByRole('group', { name: 'Max usage' })
|
||
await expect(tier.getByRole('button')).toHaveText(maxes.map((m) => m.name.replace(/^Max\s*/i, '')))
|
||
for (const m of [...maxes].reverse().concat(maxes[0])) {
|
||
await tier.getByRole('button', { name: m.name, exact: true }).click()
|
||
await expect(tier.getByRole('button', { name: m.name, exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||
await expect(max).toContainText(money(m.price))
|
||
await expect(max).toContainText(m.description)
|
||
for (const f of m.features) await expect(max).toContainText(f)
|
||
for (const o of maxes.filter((x) => x !== m)) await expect(max).not.toContainText(o.description)
|
||
expect(bought(await max.getByRole('link', { name: 'Get Max' }).getAttribute('href')).searchParams.get('plan')).toBe(m.slug)
|
||
}
|
||
const [maxBox, tierBox, maxName] = await Promise.all([max.boundingBox(), tier.boundingBox(), max.getByRole('heading', { name: 'Max' }).boundingBox()])
|
||
expect(tierBox!.x + tierBox!.width, 'inside the card').toBeLessThanOrEqual(maxBox!.x + maxBox!.width)
|
||
expect(Math.abs(tierBox!.y + tierBox!.height / 2 - (maxName!.y + maxName!.height / 2)), 'on the name’s row').toBeLessThan(20)
|
||
|
||
// Get <plan> signs up first, then opens the checkout on the plan; a year adds the term.
|
||
const buy = card(page, paid.name).getByRole('link', { name: `Get ${paid.name}` })
|
||
const next = async () => bought(await buy.getAttribute('href'))
|
||
const month = await next()
|
||
expect(month.pathname).toBe('/pay/confirm/card')
|
||
expect(month.searchParams.get('plan')).toBe(paid.slug)
|
||
expect(month.searchParams.has('interval')).toBe(false)
|
||
|
||
// A plan for one person is sold by the month: no term to choose, no year on the page.
|
||
await expect(page.getByRole('switch', { name: 'Bill yearly' }), 'no annual switch on the page').toHaveCount(0)
|
||
await expect(page.getByRole('group', { name: /billing period$/ }), 'no term pill on a personal plan').toHaveCount(0)
|
||
for (const name of ['Free', paid.name, 'Max']) await expect(card(page, name), `no year on ${name}`).not.toContainText(/yearly|annual/i)
|
||
|
||
// Team is sold by the seat, from the catalog's floor, and chooses its own
|
||
// term in its header row, saving what its own row says.
|
||
await check(page.getByRole('radio', { name: 'Business' }))
|
||
const seats = Number(team.limits?.minSeats ?? 1)
|
||
const t = card(page, team.name)
|
||
await expect(t).toContainText('USD/member/month')
|
||
if (seats > 1) await expect(t).toContainText(`from ${seats} members`)
|
||
const save = Math.round((1 - team.annualTotal! / (12 * team.price)) * 100)
|
||
const term = t.getByRole('group', { name: `${team.name} billing period` })
|
||
await expect(term).toBeVisible()
|
||
const teamBuy = t.getByRole('link', { name: `Get ${team.name}` })
|
||
expect(bought(await teamBuy.getAttribute('href')).searchParams.get('interval')).toBe('year')
|
||
await expect(term.getByRole('button', { name: `Annual, save ${save}%` })).toHaveAttribute('aria-pressed', 'true')
|
||
await expect(t).toContainText(money(Math.round(team.annualTotal! / 12)))
|
||
await expect(t).toContainText(`Billed ${money(team.annualTotal!)} per member yearly`)
|
||
await term.getByRole('button', { name: 'Monthly' }).click()
|
||
await expect(term.getByRole('button', { name: 'Monthly' })).toHaveAttribute('aria-pressed', 'true')
|
||
expect(bought(await teamBuy.getAttribute('href')).searchParams.has('interval')).toBe(false)
|
||
// The pill sits in the header row beside the name, inside the card.
|
||
const [cardBox, pill, name] = await Promise.all([t.boundingBox(), term.boundingBox(), t.getByRole('heading', { name: team.name }).boundingBox()])
|
||
expect(pill!.x + pill!.width, 'inside the card').toBeLessThanOrEqual(cardBox!.x + cardBox!.width)
|
||
expect(Math.abs(pill!.y + pill!.height / 2 - (name!.y + name!.height / 2)), 'on the name’s row').toBeLessThan(20)
|
||
|
||
await expect(card(page, 'Enterprise').getByRole('link', { name: 'Contact Sales' })).toHaveAttribute('href', '/contact-sales')
|
||
// Team's way in and Enterprise's stand on one line, whatever each card says above them.
|
||
const [teamWay, entWay] = await Promise.all([
|
||
t.getByRole('link', { name: `Get ${team.name}` }).boundingBox(),
|
||
card(page, 'Enterprise').getByRole('link', { name: 'Contact Sales' }).boundingBox(),
|
||
])
|
||
expect(Math.abs(teamWay!.y - entWay!.y), 'the ways in on one line').toBeLessThan(2)
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('a coupon in the address prices each plan it covers by its first month, and every way to buy carries the code and the campaign', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page)
|
||
// Commerce's quote: half off Pro's first month (registered last, so it answers first).
|
||
await page.route(
|
||
(u) => u.pathname === '/v1/billing/plans' && u.searchParams.has('coupon'),
|
||
(route) => {
|
||
const coupon = new URL(route.request().url()).searchParams.get('coupon')!
|
||
return route.fulfill({ json: PLANS.map((r) => (r.slug === paid.slug ? { ...r, couponCode: coupon, couponPercent: 50, couponFirstCents: r.price / 2 } : { ...r, couponError: 'not_applicable' })) })
|
||
},
|
||
)
|
||
try {
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(`${server.url}/pricing?coupon=50OFF&utm_source=email&utm_campaign=founder`, { waitUntil: 'load' })
|
||
const c = card(page, paid.name)
|
||
await expect(c).toContainText(`First month ${money(paid.price / 2)}, then ${money(paid.price)}/month`, { timeout: 20_000 })
|
||
const buy = c.getByRole('link', { name: 'Claim 50% off' })
|
||
const pay = bought(await buy.getAttribute('href'))
|
||
expect(Object.fromEntries(pay.searchParams)).toMatchObject({ plan: paid.slug, coupon: '50OFF', utm_source: 'email', utm_campaign: 'founder' })
|
||
expect(await page.evaluate(() => sessionStorage.getItem('checkout.coupon')), 'the tab keeps the code').toBe('50OFF')
|
||
// Free stands beside the half price, as on every other visit.
|
||
await expect(card(page, 'Free').getByRole('link', { name: 'Try Hanzo' }), 'Free beside the discount').toBeVisible()
|
||
// A plan the code does not cover keeps its list price and carries no code.
|
||
const other = card(page, 'Max').getByRole('link', { name: 'Get Max' })
|
||
expect(bought(await other.getAttribute('href')).searchParams.has('coupon')).toBe(false)
|
||
// pay's pill: 56px at 16px, fully round.
|
||
expect(await buy.evaluate((el) => ({ h: Math.round(el.getBoundingClientRect().height), f: getComputedStyle(el).fontSize, r: parseFloat(getComputedStyle(el).borderTopLeftRadius) }))).toMatchObject({ h: 56, f: '16px' })
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('a code commerce quotes for several months says so, from the quote and nowhere else', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page)
|
||
await page.route(
|
||
(u) => u.pathname === '/v1/billing/plans' && u.searchParams.has('coupon'),
|
||
(route) => {
|
||
const coupon = new URL(route.request().url()).searchParams.get('coupon')!
|
||
return route.fulfill({
|
||
json: PLANS.map((r) => (r.slug === paid.slug ? { ...r, couponCode: coupon, couponPercent: 50, couponFirstCents: r.price / 2, couponMonths: 3 } : { ...r, couponError: 'not_applicable' })),
|
||
})
|
||
},
|
||
)
|
||
try {
|
||
await page.goto(`${server.url}/pricing?coupon=50OFFBETA`, { waitUntil: 'load' })
|
||
const c = card(page, paid.name)
|
||
await expect(c).toContainText(`${money(paid.price / 2)}/month for 3 months, then ${money(paid.price)}/month`, { timeout: 20_000 })
|
||
await expect(page.getByText('50% off applied for your first 3 months', { exact: true })).toBeVisible()
|
||
await expect(page.getByText(/90 days/)).toHaveCount(0)
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('with no plan marked popular, the first plan sold by card leads on a solid pill, as pay’s picker does', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page, { '/v1/billing/plans': PLANS.map((r) => ({ ...r, popular: false })) })
|
||
try {
|
||
await page.goto(`${server.url}/pricing`, { waitUntil: 'load' })
|
||
const first = PLANS.find((r) => r.category === 'personal' && r.price > 0)!
|
||
await expect(card(page, first.name).getByRole('link', { name: `Get ${first.name}` })).toHaveAttribute('data-fill', '', { timeout: 20_000 })
|
||
await expect(card(page, 'Free').getByRole('link', { name: 'Try Hanzo' })).not.toHaveAttribute('data-fill', '')
|
||
await check(page.getByRole('radio', { name: 'Business' }))
|
||
await expect(card(page, team.name).getByRole('link', { name: `Get ${team.name}` })).toHaveAttribute('data-fill', '')
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
// The code is a Personal plan's (5a41503a), and a person's plans are sold by the
|
||
// month (fbd20b92), which is the only term commerce takes a code off: Business
|
||
// sells Team at its list price on either term, whatever commerce quotes.
|
||
test('a coupon is for Personal plans, by the month, and Team keeps its list price on either term', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page)
|
||
await page.route(
|
||
(u) => u.pathname === '/v1/billing/plans' && u.searchParams.has('coupon'),
|
||
(route) => {
|
||
const coupon = new URL(route.request().url()).searchParams.get('coupon')!
|
||
const covered = [paid.slug, team.slug]
|
||
return route.fulfill({ json: PLANS.map((r) => (covered.includes(r.slug) ? { ...r, couponCode: coupon, couponPercent: 50, couponFirstCents: r.price / 2 } : { ...r, couponError: 'not_applicable' })) })
|
||
},
|
||
)
|
||
try {
|
||
await page.goto(`${server.url}/pricing?coupon=50OFF`, { waitUntil: 'load' })
|
||
const p = card(page, paid.name)
|
||
const claim = p.getByRole('link', { name: 'Claim 50% off' })
|
||
await expect(claim).toBeVisible({ timeout: 20_000 })
|
||
await expect(p.getByRole('button', { name: /^Annual/ }), 'sold by the month').toHaveCount(0)
|
||
const pay = bought(await claim.getAttribute('href'))
|
||
expect(pay.searchParams.get('coupon')).toBe('50OFF')
|
||
expect(pay.searchParams.get('interval'), 'a month, the term the code comes off').not.toBe('year')
|
||
|
||
await check(page.getByRole('radio', { name: 'Business' }))
|
||
const t = card(page, team.name)
|
||
const get = t.getByRole('link', { name: `Get ${team.name}` })
|
||
await expect(get).toBeVisible()
|
||
await expect(t.getByRole('link', { name: 'Claim 50% off' }), 'Team is sold at its list price').toHaveCount(0)
|
||
expect(bought(await get.getAttribute('href')).searchParams.has('coupon'), 'and carries no code').toBe(false)
|
||
const save = Math.round((1 - team.annualTotal! / (12 * team.price)) * 100)
|
||
await t.getByRole('button', { name: `Annual, save ${save}%` }).click()
|
||
const year = bought(await get.getAttribute('href'))
|
||
expect(year.searchParams.get('interval')).toBe('year')
|
||
expect(year.searchParams.has('coupon'), 'nor billed yearly').toBe(false)
|
||
// The footer names Enterprise beside Pricing, and the Enterprise card opens its page.
|
||
await expect(page.getByRole('contentinfo').getByRole('link', { name: 'Enterprise', exact: true })).toHaveAttribute('href', '/enterprise')
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('the tabs are pay’s words, and the Team card states its price once', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page, { '/v1/billing/plans': PLANS.map((r) => (r.slug === team.slug ? { ...r, features: [`${money(team.price)} per member a month, billed monthly`, 'Fixture team line'] } : r)) })
|
||
try {
|
||
await page.goto(`${server.url}/pricing`, { waitUntil: 'load' })
|
||
await expect(page.getByRole('radio', { name: 'Personal' })).toBeVisible({ timeout: 20_000 })
|
||
await check(page.getByRole('radio', { name: 'Business' }))
|
||
const c = card(page, team.name)
|
||
await expect(c).toContainText('Fixture team line')
|
||
await c.getByRole('group', { name: `${team.name} billing period` }).getByRole('button', { name: 'Monthly' }).click()
|
||
expect((await c.innerText()).split(money(team.price)).length - 1, 'the price, once').toBe(1)
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('a team sold in two seat types is one card that prices each seat type once, on the term picked', async ({ page }) => {
|
||
// The catalog's team group: the standard seat (the fixture's Team) and a
|
||
// premium seat beside it, both of group "team" (commerce GET /v1/billing/plans `group`).
|
||
const premium: Row = {
|
||
slug: 'team-premium',
|
||
name: `${team.name} Premium`,
|
||
description: 'Fixture: the premium seat.',
|
||
category: 'team',
|
||
group: 'team',
|
||
price: 11100,
|
||
priceAnnual: 9250,
|
||
annualTotal: 111000,
|
||
perSeat: true,
|
||
features: [`${money(11100)} per member a month`, `Everything in ${team.name}, plus:`, 'Fixture: 5x the usage of a standard seat', 'Fixture premium line'],
|
||
limits: team.limits,
|
||
}
|
||
const rows = [...PLANS.map((r) => (r.slug === team.slug ? { ...r, group: 'team' } : r)), premium]
|
||
const server = await serveExport()
|
||
await serve(page, { '/v1/billing/plans': rows })
|
||
try {
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(`${server.url}/pricing`, { waitUntil: 'load' })
|
||
await expect(page.getByRole('radio', { name: 'Personal' })).toBeVisible({ timeout: 20_000 })
|
||
await check(page.getByRole('radio', { name: 'Business' }))
|
||
const t = card(page, team.name)
|
||
await expect(card(page, premium.name), 'no card of its own').toHaveCount(0)
|
||
const list = t.locator('[data-slot="seat-types"]')
|
||
await expect(list.locator('[data-seat]')).toHaveCount(2)
|
||
// Defaults to annual
|
||
await expect(list.locator('[data-seat="team"]')).toContainText(money(Math.round(team.annualTotal! / 12)))
|
||
await expect(list.locator('[data-seat="team-premium"]')).toContainText(money(Math.round(premium.annualTotal! / 12)))
|
||
await expect(t).toContainText('Billed yearly')
|
||
// Switch to monthly
|
||
await t.getByRole('group', { name: `${team.name} billing period` }).getByRole('button', { name: 'Monthly' }).click()
|
||
await expect(list.locator('[data-seat="team"]')).toContainText(`Standard${money(team.price)} USD/member/month`)
|
||
await expect(list.locator('[data-seat="team-premium"]')).toContainText(`Premium${money(premium.price)} USD/member/month`)
|
||
// What the premium seat adds is its own first line, never its price or its heading.
|
||
await expect(list.locator('[data-seat="team-premium"]')).toContainText('Fixture: 5x the usage of a standard seat')
|
||
// Each price once on the card: the seat list is the card's price.
|
||
const text = await t.innerText()
|
||
expect(text.split(money(team.price)).length - 1, 'the standard seat, once').toBe(1)
|
||
expect(text.split(money(premium.price)).length - 1, 'the premium seat, once').toBe(1)
|
||
await expect(t).toContainText(`from ${team.limits!.minSeats} members`)
|
||
// The way in opens the team's checkout, from its floor.
|
||
const href = bought(await t.getByRole('link', { name: `Get ${team.name}` }).getAttribute('href'))
|
||
expect(href.searchParams.get('plan')).toBe(team.slug)
|
||
await page.setViewportSize({ width: 390, height: 844 })
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0)
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test("prices Kai once, at the price list's per-token rate", async ({ page }) => {
|
||
// The price list names Kai twice, as `kai` and as its canonical id
|
||
// `hanzo/kai`, because the gateway bills each id at its own row. Both are
|
||
// one model, so the page draws one card with the rates the row states: per
|
||
// million input tokens, and output free.
|
||
const kai = PRICING.hanzoModels.find((m) => m.name === 'kai')!.pricing
|
||
const per = (n: number) => (n === 0 ? 'Free' : `$${n} / MTok`)
|
||
const escape = (t: string) => t.replace(/[$/.]/g, (c) => `\\${c}`)
|
||
const server = await serveExport()
|
||
await serve(page)
|
||
try {
|
||
await page.goto(server.url + '/pricing#api', { waitUntil: 'load' })
|
||
const card = page
|
||
.locator('h3')
|
||
.filter({ hasText: /^Kai$/ })
|
||
.locator('xpath=ancestor::*[contains(., "/v1/decisions")][1]')
|
||
await expect(card).toContainText(new RegExp(`Input\\s*${escape(per(kai.input))}`))
|
||
await expect(card, 'one Kai card for its two ids').toHaveCount(1)
|
||
await expect(card).toContainText(new RegExp(`Output\\s*${per(kai.output)}`))
|
||
await expect(card).not.toContainText('N/A')
|
||
// A decision model has no chat to start.
|
||
await expect(card.getByText(/^Start Using/)).toHaveCount(0)
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('an edit to the authority reaches the page without a build', async ({ page }) => {
|
||
const server = await serveExport()
|
||
// What the authority answers after a SuperAdmin raises the plan by a dollar
|
||
// and the web_search rate by a hundredth of a cent.
|
||
const raised = PLANS.map((r) => (r.slug === paid.slug ? { ...r, price: r.price + 100 } : r))
|
||
const search = tariff.rates.find((r) => r.name === 'web_search')!
|
||
const edited = { ...PRICING, tariff: { ...tariff, rates: tariff.rates.map((r) => (r === search ? { ...r, rate_micro_usd: r.rate_micro_usd + 100 } : r)) } }
|
||
await serve(page, { '/v1/billing/plans': raised, '/v1/pricing': edited })
|
||
try {
|
||
await page.goto(server.url + '/pricing', { waitUntil: 'load' })
|
||
await expect(card(page, paid.name)).toContainText(money(paid.price + 100))
|
||
await page.goto(server.url + '/pricing#api', { waitUntil: 'load' })
|
||
await expect(page.getByRole('cell', { name: `$${(search.rate_micro_usd + 100) / 1e6}` }).first()).toBeVisible()
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('the rate card and every model’s rate are the API tab’s, and only its', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await serve(page)
|
||
try {
|
||
await page.goto(server.url + '/pricing', { waitUntil: 'load' })
|
||
await expect(card(page, 'Pro')).toBeVisible({ timeout: 20_000 })
|
||
const models = page.getByRole('heading', { name: 'Every model, at the price on your bill' })
|
||
const tools = page.locator('h2', { hasText: 'Explore pricing for tools' })
|
||
await expect(models, 'no model rates on the plans').toHaveCount(0)
|
||
await expect(tools, 'no rate card on the plans').toHaveCount(0)
|
||
await check(page.getByRole('radio', { name: 'API' }))
|
||
await expect(models).toBeVisible({ timeout: 20_000 })
|
||
await expect(tools).toBeVisible()
|
||
await check(page.getByRole('radio', { name: 'Personal' }))
|
||
await expect(models, 'gone again off the API tab').toHaveCount(0)
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
|
||
test('says api.hanzo.ai is unreachable, and shows no price in its place', async ({ page }) => {
|
||
const server = await serveExport()
|
||
await block(page)
|
||
try {
|
||
await page.goto(server.url + '/pricing#api', { waitUntil: 'load' })
|
||
const down = page.locator('[data-api="unreachable"]')
|
||
// On the API tab the plans, the rate card and the model rates each say so.
|
||
await expect(down).toHaveCount(3, { timeout: 20_000 })
|
||
await expect(down.first()).toContainText('api.hanzo.ai is unreachable')
|
||
await expect(page.locator('[data-api="loading"]')).toHaveCount(0)
|
||
for (const r of PLANS) await expect(card(page, r.name), `no card for ${r.slug}`).toHaveCount(0)
|
||
await expect(page.getByText('USD/month')).toHaveCount(0)
|
||
await expect(page.getByRole('cell', { name: /^\$/ })).toHaveCount(0)
|
||
} finally {
|
||
await server.close()
|
||
}
|
||
})
|
||
})
|