mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
_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]>
202 lines
9.0 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|