Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
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.
2026-10-22 19:07:20 -07:00

191 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})