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