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.
191 lines
10 KiB
TypeScript
191 lines
10 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
import { serveExport } from './export'
|
||
import { signIn } from '../session'
|
||
|
||
/**
|
||
* THE PLANS ARE THE PAY SITE'S, AND EVERY MOVE PUTS THE PHONE'S DRAWER AWAY.
|
||
*
|
||
* View all plans in the account menu (@hanzo/app Me) names the builder's plans
|
||
* address, and so does a typed `/dev/-/plans`. This host leaves for the pay
|
||
* site's plans there (app/_web.tsx `leave`), `/pay/cart` for the organization in
|
||
* use, with the page to come back to once paid — at once, with nothing read
|
||
* first, whatever plan the organization holds.
|
||
*
|
||
* A phone reaches the menu through the drawer, by touch, and every move puts the
|
||
* drawer away — the one to where the reader already is included, which used to
|
||
* close the menu and leave the drawer standing over the page it had asked for.
|
||
* The drawer is @hanzo/app' (Drawer.tsx): the menu stands behind on the
|
||
* window's floor and the pane slides toward the end over it, raised, a strip of
|
||
* it left to tap. Measured on the shipped bytes, in Chromium and in WebKit.
|
||
*/
|
||
|
||
const PAY = 'https://hanzo.ai/pay'
|
||
const PHONE = { viewport: { width: 393, height: 659 }, hasTouch: true, isMobile: true }
|
||
|
||
let base: string
|
||
let stop: () => Promise<void>
|
||
test.beforeAll(async () => {
|
||
const served = await serveExport()
|
||
base = served.url
|
||
stop = served.close
|
||
})
|
||
test.afterAll(async () => stop())
|
||
|
||
/** A signed-in admin of `org`, on Free or on the plan `held` names; the pay site answered here. */
|
||
async function open(page: Page, org: string, { personal = true, held = null as string | null } = {}) {
|
||
await signIn(page, { sub: org, name: org, email: `${org}@example.com`, orgs: [{ org, role: 'admin' }] })
|
||
await page.route(
|
||
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/oauth/'),
|
||
(route) => {
|
||
const path = new URL(route.request().url()).pathname
|
||
if (path === '/v1/billing/tier')
|
||
return route.fulfill({ json: { user: org, tier: held ? { name: held, displayName: held } : { name: 'free', displayName: 'Free' }, balance: { currency: 'usd', effectiveAvailable: 0 } } })
|
||
if (path === '/v1/billing/balance') return route.fulfill({ json: { balance: 0, holds: 0, available: 0 } })
|
||
if (path === '/v1/billing/subscriptions')
|
||
return route.fulfill({
|
||
json: held
|
||
? { count: 1, subscriptions: [{ id: 'sub_1', status: 'active', quantity: 2, currentPeriodEnd: '2026-11-15T00:00:00Z', cancelAtPeriodEnd: false, plan: { id: held, name: held, price: 2500, currency: 'usd', interval: 'month' } }] }
|
||
: { count: 0, subscriptions: [] },
|
||
})
|
||
if (path === '/v1/iam/organizations') return route.fulfill({ json: { organizations: [{ name: org, displayName: org, isPersonal: personal }] } })
|
||
if (path.endsWith('/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
|
||
return route.fulfill({ json: {} })
|
||
},
|
||
)
|
||
await page.context().route(`${PAY}/**`, (route) => route.fulfill({ contentType: 'text/html', body: '<title>Plans</title>pay' }))
|
||
}
|
||
|
||
/** Where the page went: the pay address and its query. */
|
||
const landed = (page: Page) => {
|
||
const u = new URL(page.url())
|
||
return { at: `${u.origin}${u.pathname}`, org: u.searchParams.get('org'), back: u.searchParams.get('returnUrl') }
|
||
}
|
||
|
||
const drawer = (page: Page) => page.locator('[data-slot="drawer"]')
|
||
const menu = (page: Page) => page.getByRole('menu', { name: 'Account' })
|
||
|
||
/** The drawer, opened from the bar, and its account menu, opened from its foot — both by touch. */
|
||
async function account(page: Page, org: string) {
|
||
await page.locator('[data-slot="app-bar"]').getByRole('button', { name: 'Open sidebar' }).tap({ timeout: 30_000 })
|
||
await drawer(page).getByRole('button', { name: `Account: ${org} · ${org}` }).tap()
|
||
await expect(menu(page)).toBeVisible()
|
||
}
|
||
|
||
/** The account menu's plans row, on the menu itself and named by the plan the organization is on (@hanzo/app Me `upgrade`). */
|
||
async function usage(page: Page) {
|
||
await expect(menu(page).getByRole('menuitem', { name: /^(Upgrade|Enterprise|Agency) plan$/ })).toBeVisible()
|
||
}
|
||
|
||
test.describe('on a phone', () => {
|
||
test.use(PHONE)
|
||
|
||
test('Upgrade plan in the drawer opens the plans to choose from for a personal organization on Free', async ({ page }) => {
|
||
await open(page, 'marymaryjane318')
|
||
await page.goto(`${base}/`)
|
||
await account(page, 'marymaryjane318')
|
||
await usage(page)
|
||
await menu(page).getByRole('menuitem', { name: 'Upgrade plan' }).tap()
|
||
await expect.poll(() => landed(page).at, { timeout: 15_000 }).toBe(`${PAY}/cart`)
|
||
expect(landed(page)).toEqual({ at: `${PAY}/cart`, org: 'marymaryjane318', back: `${base}/` })
|
||
})
|
||
|
||
test("The plans row opens a team organization's plans, for that organization, though it holds one", async ({ page }) => {
|
||
await open(page, 'webby-ai', { personal: false, held: 'team' })
|
||
await page.goto(`${base}/`)
|
||
await account(page, 'webby-ai')
|
||
await usage(page)
|
||
await menu(page).getByRole('menuitem', { name: /^(Upgrade|Enterprise|Agency) plan$/ }).tap()
|
||
await expect.poll(() => landed(page).at, { timeout: 15_000 }).toBe(`${PAY}/cart`)
|
||
expect(landed(page)).toEqual({ at: `${PAY}/cart`, org: 'webby-ai', back: `${base}/` })
|
||
})
|
||
|
||
test('Settings in the drawer opens the site’s Settings at /settings, and the drawer is put away', async ({ page }) => {
|
||
await open(page, 'marymaryjane318')
|
||
await page.goto(`${base}/dev`)
|
||
await account(page, 'marymaryjane318')
|
||
await menu(page).getByRole('menuitem', { name: 'Settings', exact: true }).tap()
|
||
await expect(menu(page)).toHaveCount(0)
|
||
await expect.poll(() => new URL(page.url()).pathname, { timeout: 20_000 }).toBe('/settings')
|
||
await expect(drawer(page), 'the drawer is put away').toHaveCount(0)
|
||
})
|
||
|
||
// Settings leaves the app (above), so the move to where the reader already is is the foot's Chat
|
||
// pressed on a new chat: the address does not change, and the drawer still goes.
|
||
test('a move to the page the reader is on puts the drawer away', async ({ page }) => {
|
||
await open(page, 'marymaryjane318')
|
||
await page.goto(`${base}/chat`)
|
||
await page.locator('[data-slot="app-bar"]').getByRole('button', { name: 'Open sidebar' }).tap({ timeout: 30_000 })
|
||
await expect(drawer(page)).toBeVisible()
|
||
await drawer(page).locator('[data-slot="drawer-foot"]').getByRole('button', { name: 'New chat' }).tap()
|
||
await expect(drawer(page), 'the drawer is put away though the address has not moved').toHaveCount(0)
|
||
expect(new URL(page.url()).pathname).toBe('/chat')
|
||
})
|
||
|
||
test('the menu stands behind on the floor, the pane is raised over it and slid toward the end, and a tap on what is left of it slides it home', async ({ page }) => {
|
||
await open(page, 'marymaryjane318')
|
||
await page.goto(`${base}/`)
|
||
await page.locator('[data-slot="app-bar"]').getByRole('button', { name: 'Open sidebar' }).tap({ timeout: 30_000 })
|
||
const menu = drawer(page).locator('[data-slot="menu"]')
|
||
await expect(menu).toBeVisible()
|
||
const look = await page.evaluate(() => {
|
||
const probe = document.createElement('div')
|
||
probe.style.backgroundColor = 'var(--pane-floor)'
|
||
document.body.append(probe)
|
||
const floor = getComputedStyle(probe).backgroundColor
|
||
probe.remove()
|
||
const pane = document.querySelector('[data-slot="frame"]') as HTMLElement
|
||
const s = getComputedStyle(pane)
|
||
return {
|
||
floor,
|
||
menu: getComputedStyle(document.querySelector('[data-slot="drawer"] [data-slot="menu"]')!).backgroundColor,
|
||
drop: s.boxShadow,
|
||
z: Number(s.zIndex),
|
||
lift: pane.querySelector(':scope > [data-slot="lift"]') !== null,
|
||
}
|
||
})
|
||
expect(look.menu, 'the menu on the floor').toBe(look.floor)
|
||
expect(look.z, 'the pane over the menu').toBeGreaterThan(0)
|
||
expect(look.drop, 'casting a drop on it').not.toBe('none')
|
||
expect(look.lift, 'on its own opaque fill').toBe(true)
|
||
// The slide is animated, so it is measured where it rests.
|
||
const boxes = () => Promise.all([menu.boundingBox(), page.locator('[data-slot="frame"]').boundingBox()])
|
||
await expect
|
||
.poll(async () => {
|
||
const [a, b] = await boxes()
|
||
return Math.abs(b!.x - (a!.x + a!.width))
|
||
}, { message: 'the pane slid to the menu’s end' })
|
||
.toBeLessThanOrEqual(1)
|
||
const [m, p] = await boxes()
|
||
expect(m!.x, 'the menu at the start edge').toBeLessThanOrEqual(1)
|
||
expect(393 - p!.x, 'a thumb’s strip of the pane in sight').toBeGreaterThanOrEqual(44)
|
||
// The foot: the account and the accent New chat.
|
||
const foot = drawer(page).locator('[data-slot="drawer-foot"]')
|
||
await expect(foot.getByRole('button', { name: 'Account: marymaryjane318 · marymaryjane318' })).toBeVisible()
|
||
await expect(foot.getByRole('button', { name: 'New chat' })).toBeVisible()
|
||
await page.locator('[data-slot="drawer-close"]').tap()
|
||
await expect(drawer(page)).toHaveCount(0)
|
||
})
|
||
})
|
||
|
||
test('on a desk, Upgrade plan in the account menu opens the same plans', async ({ page }) => {
|
||
await open(page, 'marymaryjane318')
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(`${base}/dev`)
|
||
await page.getByRole('button', { name: 'Account: marymaryjane318 · marymaryjane318' }).click({ timeout: 30_000 })
|
||
await usage(page)
|
||
await menu(page).getByRole('menuitem', { name: 'Upgrade plan' }).click()
|
||
await expect.poll(() => landed(page).at, { timeout: 15_000 }).toBe(`${PAY}/cart`)
|
||
expect(landed(page)).toEqual({ at: `${PAY}/cart`, org: 'marymaryjane318', back: `${base}/dev` })
|
||
})
|
||
|
||
test("the plans' own address leaves for the pay site in its place, and comes back to Dev", async ({ page }) => {
|
||
await open(page, 'marymaryjane318')
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await page.goto(`${base}/robots.txt`)
|
||
const before = await page.evaluate(() => history.length)
|
||
await page.goto(`${base}/dev/-/plans`)
|
||
await expect.poll(() => landed(page).at, { timeout: 30_000 }).toBe(`${PAY}/cart`)
|
||
expect(landed(page)).toEqual({ at: `${PAY}/cart`, org: 'marymaryjane318', back: `${base}/dev` })
|
||
expect(await page.evaluate(() => history.length), 'the plans address is replaced, so Back does not land on it').toBe(before + 1)
|
||
})
|