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.
179 lines
9.9 KiB
TypeScript
179 lines
9.9 KiB
TypeScript
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)
|
||
})
|
||
})
|