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

256 lines
14 KiB
TypeScript

import { expect, test, type Locator, type Page } from '@playwright/test'
import { serveExport } from './export'
import { issuer, jwt, type Options } from './issuer'
import { signIn } from '../session'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
/**
* THE WAY IN IS ONE PAGE WITH DIFFERENT WORDS (components/shell/focus).
*
* Every page a person passes on the way in — /login, /signup and each of their
* steps, the terms that make an account, /forget, /join, /login/mfa, the
* callback's refusal, and /onboarding — stands under the shared bar with the
* Hanzo AI wordmark at its top left, uncovered, and (signed in) the account at
* its far right with nothing past it. Under the bar: one light, painted on the
* page and on the bar alike; one column at the sign-in card's measure, centred
* across the window and down the part of it under the bar; one title size at a
* width (32px, 26px on a phone, the regular weight, -0.015em); and Hanzo's mark
* at the foot. Measured off the export at 1440 and 390, rather than read from
* the source, because the step that lost the wordmark (the terms, laid over the
* bar from <body>) was valid markup on a green build.
*/
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
const TOKEN = jwt({ sub: 'ada', name: 'ada', email: '[email protected]', orgs: [{ org: 'ada', role: 'owner' }] })
const card = (page: Page) => page.getByRole('region', { name: /^(Log in|Sign up)$/ })
/** IAM answered as the login gate answers it: nothing off this machine, the credential calls on this origin. */
async function world(page: Page, reply: Options['reply'] = {}) {
await page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
await page.route(`${base}/v1/event`, (route) => route.fulfill({ json: {} }))
await issuer(page, { site: base, reply, token: TOKEN })
}
/** A signed-in account IAM made a minute ago, owing the terms or not, its organization on no plan. */
async function member(page: Page, owed: boolean) {
await signIn(page, { sub: 'ada', name: 'ada', email: '[email protected]', orgs: [{ org: 'ada', role: 'owner' }] })
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/iam/preferences') return route.fulfill({ json: { status: 'ok', data: owed ? {} : { terms: { terms: TERMS_VERSION, aup: AUP_VERSION } } } })
if (path === '/v1/iam/account') return route.fulfill({ json: { status: 'ok', data: { name: 'ada', email: '[email protected]', createdTime: new Date(Date.now() - 60_000).toISOString() } } })
if (path === '/v1/billing/tier') return route.fulfill({ json: { user: 'ada', plan: '', tier: { name: 'free', displayName: 'free' }, balance: { currency: 'usd' } } })
return route.fulfill({ json: {} })
},
)
}
/** The address typed and Continue with email pressed. */
async function typed(page: Page) {
await card(page).getByLabel('Email').fill('[email protected]')
await card(page).getByRole('button', { name: 'Continue with email' }).click()
}
/** A page opened signed out, ready once `ready` shows. */
const plain = (path: string, ready: (page: Page) => Locator) => async (page: Page) => {
await world(page)
await page.goto(`${base}${path}`)
await expect(ready(page)).toBeVisible({ timeout: 20_000 })
}
/** /onboarding for an account made a minute ago, owing the terms or not, at its heading. */
const onboarding = (owed: boolean, heading: string) => async (page: Page) => {
await member(page, owed)
await page.goto(`${base}/onboarding`)
await expect(page.getByRole('heading', { name: heading })).toBeVisible({ timeout: 20_000 })
}
/** IAM's word on the address: an account that signs in by code. */
const CODED = { status: 'ok', data: { account: true, password: false } }
/** Each page of the way in, opened at its step. A `signed` page draws the account in the bar. */
const PAGES: { name: string; signed?: boolean; open: (page: Page) => Promise<void> }[] = [
{ name: '/login', open: plain('/login', (p) => card(p).getByLabel('Email')) },
{ name: '/signup', open: plain('/signup', (p) => card(p).getByLabel('Email')) },
{
name: 'the link',
open: async (p) => {
await world(p, { '/v1/iam/auth/identifier': CODED })
await p.goto(`${base}/login`)
await typed(p)
await expect(card(p).getByText('To continue, click the link sent to')).toBeVisible()
},
},
{
name: 'the terms that make an account',
open: async (p) => {
await world(p, { '/v1/iam/auth/identifier': CODED, '/v1/iam/login': (b) => ({ status: 'ok', data: b.create ? 'c0de' : 'SignupRequired' }) })
await p.goto(`${base}/signup`)
await typed(p)
await card(p).getByRole('button', { name: 'Enter verification code' }).click()
await card(p).getByRole('textbox', { name: 'Verification code' }).fill('123456')
await card(p).getByRole('button', { name: 'Verify email address' }).click()
await expect(p.getByRole('heading', { name: 'Create your account' })).toBeVisible()
},
},
{ name: '/forget', open: plain('/forget', (p) => p.getByRole('button', { name: 'Send code' })) },
{ name: '/join', open: plain('/join', (p) => p.getByText('This invite link is incomplete.')) },
{ name: '/login/mfa', open: plain('/login/mfa', (p) => p.getByRole('button', { name: 'Verify' })) },
{ name: 'the callback refused', open: plain('/auth/callback?error=access_denied', (p) => p.getByRole('button', { name: 'Try again' })) },
{ name: '/onboarding terms', signed: true, open: onboarding(true, 'Create your account') },
{ name: '/onboarding question', signed: true, open: onboarding(false, 'How will you use Hanzo?') },
]
/** What the page draws, read off the window: the bar, the wordmark, the account, the column, the title, the mark and the light. */
function measure(page: Page) {
return page.evaluate(() => {
const box = (el: Element) => {
const r = el.getBoundingClientRect()
return { x: r.x, y: r.y, w: r.width, h: r.height, right: r.right, bottom: r.bottom }
}
const shown = (el: Element) => {
const r = el.getBoundingClientRect()
const s = getComputedStyle(el)
return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'
}
const bar = document.querySelector('header[data-slot="masthead"]')!
const word = bar.querySelector('a[aria-label="Hanzo AI home"]')!
const w = box(word)
const hit = document.elementFromPoint(w.x + w.w / 2, w.y + w.h / 2)
// The account: its row on a wide window, its face's button on a phone; and
// whatever else the bar draws that stands past it.
const account = [...bar.querySelectorAll('[aria-label="Account"], button[aria-label^="Account:"]')].find(shown) ?? null
const past = account
? [...bar.querySelectorAll('a, button, img, svg')]
.filter((el) => shown(el) && !account.contains(el) && el.getBoundingClientRect().left >= account.getBoundingClientRect().right - 1)
.map((el) => el.getAttribute('aria-label') ?? el.textContent?.trim() ?? el.tagName)
: []
// The column on top: a step laid over the page draws its own, inside the page's.
const columns = [...document.querySelectorAll('[data-slot="column"]')].filter(shown)
const column = columns[columns.length - 1]
const section = column.closest('section')!
const h1 = section.querySelector('h1')!
const type = getComputedStyle(h1)
const mark = section.querySelector(':scope > [data-slot="mark"]')
const glow = section.querySelector(':scope > [data-slot="glow"]')
return {
vw: innerWidth,
vh: innerHeight,
bar: box(bar),
barLight: getComputedStyle(bar).backgroundImage,
word: w,
wordOnTop: Boolean(hit && word.contains(hit)),
account: account ? box(account) : null,
past,
column: box(column),
section: box(section),
title: { size: parseFloat(type.fontSize), weight: type.fontWeight, track: parseFloat(type.letterSpacing) },
mark: mark ? box(mark) : null,
light: glow ? getComputedStyle(glow).backgroundImage : '',
scroll: document.documentElement.scrollWidth,
tall: document.documentElement.scrollHeight,
}
})
}
for (const [width, height] of [
[1440, 900],
[390, 844],
] as const) {
test(`at ${width}px every page of the way in is one page: the wordmark, the light, one centred column, one title, the mark`, async ({ browser }) => {
test.setTimeout(240_000)
const sizes = new Set<number>()
for (const at of PAGES) {
// The reader the project's gates are: the cookie question answered.
const page = await browser.newPage({ viewport: { width, height }, storageState: test.info().project.use.storageState })
await page.emulateMedia({ reducedMotion: 'reduce' })
await at.open(page)
await page.waitForTimeout(400)
const m = await measure(page)
const where = `${at.name} at ${width}px`
// The wordmark at the top left, a link home, and nothing drawn over it.
expect(m.word.y, `${where}: the wordmark is in the bar`).toBeGreaterThanOrEqual(m.bar.y)
expect(m.word.bottom, `${where}: the wordmark is in the bar`).toBeLessThanOrEqual(m.bar.bottom + 1)
expect(m.word.x, `${where}: the wordmark starts the bar`).toBeLessThanOrEqual(80)
expect(m.wordOnTop, `${where}: nothing covers the wordmark`).toBe(true)
// Signed in, the account at the far right, and nothing past it.
if (at.signed) {
expect(m.account, `${where}: the account is in the bar`).not.toBeNull()
expect(m.past, `${where}: nothing stands past the account`).toEqual([])
expect(m.vw - m.account!.right, `${where}: the account stands at the bar's end`).toBeLessThanOrEqual(80)
}
// One light, on the page and on the bar.
expect(m.light, `${where}: the page stands on the light`).toContain('radial-gradient')
expect(m.barLight, `${where}: the bar paints the same light`).toContain('radial-gradient')
// One column, centred across the window and, where it fits, down the window under the bar.
expect(Math.abs(m.column.x - (m.vw - m.column.right)), `${where}: centred across`).toBeLessThanOrEqual(2)
const under = m.vh - m.bar.bottom
if (m.column.h + 2 * 88 <= under && m.section.y <= m.bar.bottom + 1) {
const middle = m.bar.bottom + under / 2
expect(Math.abs(m.column.y + m.column.h / 2 - middle), `${where}: centred down`).toBeLessThanOrEqual(4)
expect(m.tall, `${where}: a page that fits does not scroll`).toBeLessThanOrEqual(m.vh)
}
expect(m.scroll, `${where}: no sideways scroll`).toBeLessThanOrEqual(m.vw)
// One title: pay's size at this width, the regular weight, -0.015em.
expect(m.title.size, `${where}: the title's size`).toBe(width < 640 ? 26 : 32)
expect(m.title.weight, `${where}: the title's weight`).toBe('400')
expect(m.title.track, `${where}: the title's tracking`).toBeCloseTo(-0.015 * m.title.size, 1)
sizes.add(m.title.size)
// Hanzo's mark at the foot, in the middle, under the column.
expect(m.mark, `${where}: the mark at the foot`).not.toBeNull()
expect(Math.abs(m.mark!.x + m.mark!.w / 2 - m.vw / 2), `${where}: the mark is centred`).toBeLessThanOrEqual(2)
expect(m.mark!.y, `${where}: the mark is under the column`).toBeGreaterThanOrEqual(m.column.bottom)
await page.close()
}
expect([...sizes], 'one title size across the way in').toHaveLength(1)
})
}
// The question's two cards hold their way on: stacked on a phone, a card is as
// tall as what it holds, where the column once squeezed it to its floor and its
// button stood over the edge and under the next card.
for (const width of [1440, 390]) {
test(`at ${width}px each of the question's cards holds its way on`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 })
await onboarding(false, 'How will you use Hanzo?')(page)
for (const side of ['Personal', 'Business']) {
const card = page.getByRole('link', { name: new RegExp(`^${side}`) })
const [box, way] = await Promise.all([card.boundingBox(), card.getByText(`See ${side.toLowerCase()} plans`).boundingBox()])
expect(way!.y + way!.height, `${side}: the way on stands inside its card`).toBeLessThanOrEqual(box!.y + box!.height - 8)
}
})
}
// The export's own first paint, before any script: the page is sized from the
// bar's stated height, never from `--header`, which design holds at 0 until the
// bar has measured itself. Sized from it, the column sat half a bar low and the
// page scrolled until the script ran.
test('before its script runs, the way in already stands centred under the bar, and does not scroll', async ({ page }) => {
await page.route((u) => u.pathname.startsWith('/_next/static/') && u.pathname.endsWith('.js'), (route) => route.abort())
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/forget`)
const m = await page.evaluate(() => {
const bar = document.querySelector('header[data-slot="masthead"]')!.getBoundingClientRect()
const column = document.querySelector('[data-slot="column"]')!.getBoundingClientRect()
return { bar: bar.bottom, top: column.top, bottom: column.bottom, vh: innerHeight, tall: document.documentElement.scrollHeight }
})
expect(Math.abs((m.top + m.bottom) / 2 - (m.bar + (m.vh - m.bar) / 2)), 'centred down').toBeLessThanOrEqual(4)
expect(m.tall, 'no scroll').toBeLessThanOrEqual(m.vh)
})