Files
hanzo.ai/e2e/gates/upgrade.spec.ts
fbc926f525 The app is @hanzo/rooms 0.1.55's App, at paths under /chat and /dev, behind the organization gate
_web.tsx draws rooms' App inside rooms' `Orgs`, as every room is: a reader
with no organization makes one, one with several picks one, and a team on Free
is asked for the Team plan before the app is drawn; a personal organization on
Free opens it. The app reads its place from the path: /chat and /chat/<id> are
Chat, a path under /dev is Dev. /?chat=<id> and /chat?thread=<id> move to
/chat/<id>, /?at= to /dev?at=, whose place the door replaces with its path, as
it does the builder's grammar rooms 0.1.35-0.1.38 wrote under /dev.

The gates read the app at its paths and the rooms' shell at /home, with new
Linux baselines for it; the limit banner, the model chip and the side panel
are held to what rooms 0.1.55 draws; the landing's budget is 445 KB, since
rooms' lib/host carries @hanzo/build's route grammar. app/gui.css is what the
build writes.


Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 15:05:24 -07:00

202 lines
9.0 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { AUTO, FREE, PAID, arrive, kept, pick } from './chat'
import { serveExport } from './export'
import { PLANS } from './api'
/**
* EVERY MODEL IS CHOSEN; THE GATEWAY SAYS WHAT THE PLAN DOES NOT INCLUDE.
*
* A reader on Free, in /chat, picks any model and gets it: nothing is withheld
* at the pick. A turn the gateway refuses with 402 `plan_required` opens the
* upgrade, titled with the model. The prices are the catalog's first personal
* plan that charges, as the plan authority answers when the page asks (served
* here, e2e/gates/api), and the button goes to pay's cart for that plan.
*/
const ROW = PLANS.find((p) => p.category === 'personal' && !p.contactSales && p.price > 0)!
const PLAN = { id: ROW.slug, name: ROW.name }
const MONTH = ROW.price / 100
const YEAR = ROW.annualTotal! / 100
const SAVE = Math.round((1 - YEAR / (12 * MONTH)) * 100)
/** The address a refusal names for the upgrade. */
const UPGRADE = `https://hanzo.ai/pay?plan=${PLAN.id}&from=gateway`
/** The gateway refuses every turn 402 `plan_required`: the plan does not include the model. `upgrade` is the way past it names, when given. */
async function refuse(page: Page, upgrade?: string) {
await page.route(
(url) => url.pathname === '/v1/chat/completions',
(route) =>
route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'plan_required', message: 'This model needs a paid plan.', ...(upgrade ? { upgrade_url: upgrade } : {}) } }),
}),
)
}
/** Sends one message from the composer. */
async function send(page: Page, text: string) {
const field = page.locator('textarea').first()
await expect(field).toBeVisible({ timeout: 20_000 })
await field.fill(text)
await field.press('Enter')
}
const ask = (page: Page) => page.getByRole('dialog', { name: /Hanzo/ })
test.describe('the Free plan asks for the model it does not include', () => {
// A laptop window, where the composer's panel opens above its chip to keep the picker on screen.
test.use({ viewport: { width: 1440, height: 900 } })
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('a Free reader whose turn the gateway refuses: the ask names the model, the term moves the price', async ({ page }) => {
await refuse(page)
await arrive(page, base, 'free', FREE.id)
await pick(page, PAID.id)
expect(await kept(page)).toBe(PAID.id)
await send(page, 'hello')
const dialog = ask(page)
await expect(dialog).toBeVisible({ timeout: 20_000 })
await expect(dialog.getByRole('heading', { name: `Use ${PAID.name} with Hanzo ${PLAN.name}` })).toBeVisible()
for (const line of [
'Usage included on every model',
'Every model, including Enso Ultra and Kai',
'Hanzo Team: chat, meetings, calendar and contacts',
'Automations, with daily runs included',
]) {
await expect(dialog.getByText(line, { exact: true })).toBeVisible()
}
await expect(dialog.getByText(/subject to change|Memory that carries|Hanzo Studio/)).toHaveCount(0)
await expect(dialog.getByRole('link', { name: 'See all plans' })).toHaveAttribute('href', '/pricing')
// A month: pay's cart for the plan, returning here.
const buy = dialog.getByRole('link', { name: /^Upgrade to / })
await expect(buy).toHaveText(`Upgrade to ${PLAN.name} for $${MONTH}/month`)
const monthly = new URL((await buy.getAttribute('href'))!)
expect(`${monthly.host}${monthly.pathname}`).toBe('hanzo.ai/pay/cart')
expect(monthly.searchParams.get('plan')).toBe(PLAN.id)
expect(monthly.searchParams.get('returnUrl')).toBe(`${base}/chat`)
expect(monthly.searchParams.has('interval')).toBe(false)
// A year: the catalog's total, the saving computed from it, and the term on the cart.
const yearly = dialog.getByRole('radio', { name: `Yearly · Save ${SAVE}%` })
await expect(dialog.getByRole('radio', { name: 'Monthly' })).toHaveAttribute('aria-checked', 'true')
await yearly.click()
await expect(yearly).toHaveAttribute('aria-checked', 'true')
await expect(buy).toHaveText(`Upgrade to ${PLAN.name} for $${YEAR}/year`)
const annual = new URL((await buy.getAttribute('href'))!)
expect(`${annual.host}${annual.pathname}`).toBe('hanzo.ai/pay/cart')
expect(annual.searchParams.get('plan')).toBe(PLAN.id)
expect(annual.searchParams.get('interval')).toBe('year')
// Our event and GA4's begin_checkout, once each, with the plan and the term.
// The press stays on this page so both can be read here.
const events: string[] = []
await page.route(
(url) => url.pathname === '/v1/event',
(route) => {
events.push(route.request().postData() ?? '')
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
},
)
await page.route('https://www.googletagmanager.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
)
// gtag stood up the way gtag.js leaves it: GA4 is sent from the browser only
// while gtag is running (lib/analytics/tags.ts track).
await page.evaluate(() => {
const w = window as unknown as { gtag?: (...a: unknown[]) => void; heard?: unknown[][]; google_tag_manager?: object }
w.heard = []
w.google_tag_manager = { 'G-RBVVVLFZDR': {} }
w.gtag = (...a: unknown[]) => {
w.heard!.push(a)
}
document.addEventListener('click', (e) => e.preventDefault(), { capture: true, once: true })
})
await buy.click()
const ga = await page.evaluate(() =>
(window as unknown as { heard: unknown[][] }).heard.filter((a) => a[0] === 'event'),
)
expect(ga.map((a) => a[1]), 'the pick and one begin_checkout, not one per listener').toEqual(['select_item', 'begin_checkout'])
expect(ga[1][2]).toMatchObject({ currency: 'USD', value: YEAR, interval: 'year', items: [{ item_id: PLAN.id }] })
await expect
.poll(() => events.find((body) => body.includes('plan_clicked')) ?? '', { timeout: 15_000 })
.toContain('"interval":"year"')
expect(events.find((body) => body.includes('plan_clicked'))).toContain(`"plan":"${PLAN.id}"`)
// Not now closes it, and the model picked stays picked.
await dialog.getByRole('button', { name: 'Not now' }).click()
await expect(dialog).toHaveCount(0)
expect(await kept(page)).toBe(PAID.id)
})
test('the close mark and Escape both close it', async ({ page }) => {
await refuse(page)
await arrive(page, base, 'free', PAID.id)
await send(page, 'hello')
await expect(ask(page)).toBeVisible({ timeout: 20_000 })
await ask(page).getByLabel('Close').click()
await expect(ask(page)).toHaveCount(0)
await send(page, 'again')
await expect(ask(page)).toBeVisible({ timeout: 20_000 })
await page.keyboard.press('Escape')
await expect(ask(page)).toHaveCount(0)
})
test('a turn refused 402 plan_required opens it, on the address the refusal names', async ({ page }) => {
await refuse(page, UPGRADE)
await arrive(page, base, 'free', PAID.id)
await send(page, 'hello')
const dialog = ask(page)
await expect(dialog).toBeVisible({ timeout: 20_000 })
await expect(dialog.getByRole('heading', { name: `Use ${PAID.name} with Hanzo ${PLAN.name}` })).toBeVisible()
const buy = dialog.getByRole('link', { name: /^Upgrade to / })
await expect(buy).toHaveAttribute('href', UPGRADE)
await dialog.getByRole('radio', { name: /^Yearly/ }).click()
expect(new URL((await buy.getAttribute('href'))!).searchParams.get('interval')).toBe('year')
})
test('on Free every model is chosen with no ask, the Free plan running it or not', async ({ page }) => {
await arrive(page, base, 'free', FREE.id)
await pick(page, AUTO.id)
await expect.poll(() => kept(page)).toBe(AUTO.id)
await expect(ask(page)).toHaveCount(0)
await pick(page, PAID.id)
await expect.poll(() => kept(page)).toBe(PAID.id)
await expect(ask(page)).toHaveCount(0)
})
test('a paid reader picking the same model gets it, and no ask', async ({ page }) => {
await arrive(page, base, 'dev', FREE.id)
await pick(page, PAID.id)
await expect.poll(() => kept(page)).toBe(PAID.id)
await expect(ask(page)).toHaveCount(0)
})
test('at 390px the ask fits the phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await refuse(page)
await arrive(page, base, 'free', PAID.id)
await send(page, 'hello')
const dialog = ask(page)
await expect(dialog).toBeVisible({ timeout: 20_000 })
for (const term of ['Monthly', /^Yearly/] as const) {
await dialog.getByRole('radio', { name: term }).click()
const box = (await dialog.boundingBox())!
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(390)
expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1)).toBe(false)
}
})
})