Files
hanzo.ai/e2e/gates/fit.spec.ts
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

179 lines
9.9 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'
import { config } from './heard'
/**
* THE ACCOUNT MENU FITS THE WINDOW, AND THE COOKIE BANNER NEVER COVERS THE DRAWER.
*
* The account menu (@hanzo/app Me) opens above the account row at the foot of
* the column; on a short window it is no taller than the room above that row,
* keeps the gutter clear at the top, and scrolls what does not fit to the very
* last row (app/layout.tsx). Measured from a 568px phone to a laptop, in the
* phone's drawer and in the column.
*
* The cookie banner (@hanzo/ui/consent, mounted in app/providers.tsx) stands on design's banner
* rung: over the page and its dock, under every overlay. Until it is answered
* it sits over the foot of the window; a phone drawer opened over it — the
* app's at `/`, the rooms' sidebar at `/chat` — is never covered, its account
* row takes the press, and the banner is answered once the drawer closes. On
* `/start` it is answered over the composer's dock at first paint. Measured on
* the shipped bytes, in Chromium and in WebKit.
*/
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 ORG = 'marymaryjane318'
/** A signed-in admin of a personal organization; the platform answers `{}` past its organizations. */
async function open(page: Page) {
await signIn(page, { sub: ORG, name: ORG, email: `${ORG}@example.com`, orgs: [{ org: ORG, role: 'admin' }] })
await page.route(
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/oauth/') && !u.pathname.startsWith('/v1/billing/'),
(route) => {
const path = new URL(route.request().url()).pathname
if (path === '/v1/iam/organizations') return route.fulfill({ json: { organizations: [{ name: ORG, displayName: ORG, isPersonal: true }] } })
if (path.endsWith('/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
return route.fulfill({ json: {} })
},
)
}
const me = (page: Page) => page.getByRole('button', { name: `Account: ${ORG} · ${ORG}` })
const menu = (page: Page) => page.getByRole('menu', { name: 'Account' })
/** The open menu's box against the window and the face it opens from, and whether its last row can be reached. */
async function measure(page: Page) {
await expect(menu(page).getByRole('menuitem', { name: 'Log out' })).toBeAttached()
// The menu leads with the plan's meter, read after the menu opens; once it
// has read, the menu is placed again in the next frame (@hanzo/app Me
// `Refit`). Measured as it rests: the meter read, two frames on.
await expect(menu(page).getByText('Reading your plan…')).toHaveCount(0)
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))))
return page.evaluate((org) => {
// The menu is its own panel (@hanzo/ui ≥ 8.29: no popper wrapper around it), and it is what scrolls.
const box = document.querySelector('[data-slot="dropdown-menu-content"][role="menu"][aria-label="Account"]') as HTMLElement
const row = [...document.querySelectorAll<HTMLElement>('[data-slot="me"]')].find((n) => n.getAttribute('aria-label') === `Account: ${org} · ${org}` && n.getBoundingClientRect().height > 0)!
const b = box.getBoundingClientRect()
const r = row.getBoundingClientRect()
box.scrollTop = box.scrollHeight
const last = [...box.querySelectorAll('[role="menuitem"]')].pop()!.getBoundingClientRect()
return { top: b.top, bottom: b.bottom, left: b.left, right: b.right, face: { top: r.top, bottom: r.bottom, left: r.left, right: r.right }, height: innerHeight, last: { top: last.top, bottom: last.bottom }, box: { top: b.top, bottom: b.bottom } }
}, ORG)
}
/** The menu covers none of the face: beside it, or wholly above it. */
const clear = (m: Awaited<ReturnType<typeof measure>>) => m.right <= m.face.left || m.left >= m.face.right || m.bottom <= m.face.top
for (const height of [568, 600, 660, 740, 844]) {
test.describe(`on a phone ${height}px tall`, () => {
test.use({ viewport: { width: 375, height }, hasTouch: true, isMobile: true })
test('the account menu fits in the window beside its face and scrolls to its last row', async ({ page }) => {
await open(page)
await page.goto(`${base}/`)
await page.locator('[data-slot="app-bar"]').getByRole('button', { name: 'Open sidebar' }).tap({ timeout: 30_000 })
await page.locator('[data-slot="drawer"]').getByRole('button', { name: `Account: ${ORG} · ${ORG}` }).tap()
const m = await measure(page)
expect(m.top, 'the gutter clear at the top').toBeGreaterThanOrEqual(8)
expect(m.bottom, 'and at the foot').toBeLessThanOrEqual(m.height - 8)
expect(clear(m), 'the face it opens from stays in sight').toBe(true)
expect(m.last.bottom, 'the last row is in the menu').toBeLessThanOrEqual(m.box.bottom + 1)
expect(m.last.top, 'and in the window').toBeGreaterThanOrEqual(m.box.top - 1)
})
})
}
for (const size of [
{ width: 1024, height: 568 },
{ width: 1440, height: 640 },
]) {
test(`at ${size.width}×${size.height} the account menu opens beside the rail's face and fits the window`, async ({ page }) => {
await open(page)
await page.setViewportSize(size)
await page.goto(`${base}/`)
await me(page).click({ timeout: 30_000 })
const m = await measure(page)
expect(m.top).toBeGreaterThanOrEqual(8)
expect(m.bottom).toBeLessThanOrEqual(m.height - 8)
expect(m.left, 'to the right of the face').toBeGreaterThanOrEqual(m.face.right)
expect(m.bottom, 'its foot level with the face’s').toBeCloseTo(m.face.bottom, -1)
expect(m.last.bottom).toBeLessThanOrEqual(m.box.bottom + 1)
})
}
test.describe('the cookie banner', () => {
// Unanswered, in a region that asks first.
test.use({ storageState: { cookies: [], origins: [] }, viewport: { width: 375, height: 667 }, hasTouch: true, isMobile: true })
test('never covers the drawer: the account row takes the press, and the banner is answered once the drawer closes', async ({ page }) => {
await open(page)
await config(page)
await page.goto(`${base}/`)
const banner = page.getByRole('dialog', { name: 'Cookie preferences' })
await expect(banner).toBeVisible({ timeout: 15_000 })
const foot = (await banner.boundingBox())!
expect(667 - (foot.y + foot.height), 'clear of the window foot').toBeGreaterThanOrEqual(8)
await page.locator('[data-slot="app-bar"]').getByRole('button', { name: 'Open sidebar' }).tap({ timeout: 30_000 })
const row = page.locator('[data-slot="drawer"]').getByRole('button', { name: `Account: ${ORG} · ${ORG}` })
const r = (await row.boundingBox())!
expect(r.y + r.height, 'the row stands where the banner is').toBeGreaterThan(foot.y)
const on = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest('[data-slot="me"], [role="dialog"]')?.getAttribute('data-slot') ?? null, { x: r.x + r.width / 2, y: r.y + r.height / 2 })
expect(on, 'the drawer is over the banner').toBe('me')
await row.tap()
await expect(menu(page)).toBeVisible()
// The menu put away, then the drawer, by pressing the page beside it.
await page.keyboard.press('Escape')
for (let i = 0; i < 2 && (await page.locator('[data-slot="drawer"]').count()); i++) await page.touchscreen.tap(360, 300)
await expect(page.locator('[data-slot="drawer"]')).toHaveCount(0)
await banner.getByRole('button', { name: 'Accept all' }).tap()
await expect(banner).toHaveCount(0)
})
test("over the rooms' phone sidebar at /home, the sidebar's account row takes the press", async ({ page }) => {
await signIn(page, { sub: ORG, name: ORG, email: `${ORG}@example.com`, orgs: [{ org: ORG, role: 'admin' }] })
await page.route(
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/oauth/') && !u.pathname.startsWith('/v1/billing/'),
(route) => route.fulfill({ json: {} }),
)
await config(page)
await page.goto(`${base}/home`)
const banner = page.getByRole('dialog', { name: 'Cookie preferences' })
await expect(banner).toBeVisible({ timeout: 20_000 })
await page.locator('[data-slot="bar"]').getByRole('button', { name: 'Open sidebar' }).tap()
// The sidebar's foot is the account menu every room draws (@hanzo/app Me), named by who and where.
const row = page.locator('[data-slot="list"]').getByRole('button', { name: `Account: ${ORG} · ${ORG}` })
const r = (await row.boundingBox())!
const foot = (await banner.boundingBox())!
expect(r.y + r.height, 'the row stands where the banner is').toBeGreaterThan(foot.y)
const on = await page.evaluate(({ x, y }) => (document.elementFromPoint(x, y)?.closest('[aria-label="Cookie preferences"]') ? 'banner' : 'sidebar'), { x: r.x + r.width / 2, y: r.y + r.height / 2 })
expect(on, 'the sidebar is over the banner').toBe('sidebar')
await row.tap()
await expect(menu(page)).toBeVisible()
})
test('on /start the banner is answered over the composer dock at first paint', async ({ page }) => {
await config(page)
await page.goto(`${base}/start`)
const banner = page.getByRole('dialog', { name: 'Cookie preferences' })
await expect(banner).toBeVisible({ timeout: 20_000 })
// The dock is up and takes presses, so the banner is measured with it there.
await expect.poll(() => page.locator('[data-dock]').evaluate((n) => getComputedStyle(n).pointerEvents)).toBe('auto')
const accept = banner.getByRole('button', { name: 'Accept all' })
const a = (await accept.boundingBox())!
const on = await page.evaluate(({ x, y }) => (document.elementFromPoint(x, y)?.closest('[aria-label="Cookie preferences"]') ? 'banner' : 'page'), { x: a.x + a.width / 2, y: a.y + a.height / 2 })
expect(on, 'nothing stands over the banner').toBe('banner')
await accept.tap()
await expect(banner).toHaveCount(0)
})
})