Files
hanzo.ai/e2e/gates/frame.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

317 lines
18 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 Locator, type Page } from '@playwright/test'
import { serveExport } from './export'
import { signIn } from '../session'
/**
* THE APP IS PANES ON A FRAME, AND EVERY EDGE BETWEEN THEM MOVES.
*
* Signed in, `/` draws the column, the pane and the column beside it as rounded
* panes with one gutter round the window and between them (app/_web.tsx,
* `[data-slot='frame']` in app/globals.css). Each edge is a separator that is
* dragged, keyed and double-clicked back, and the width it settles on is kept
* across a reload; pulled past its floor the column folds to a rail. The
* account row at the foot names the organization, and its menu switches between
* the token's organizations, creates one, and opens its settings and Hanzo Team.
*/
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 ORGS = [
{ org: 'antje', role: 'owner' },
{ org: 'webby-ai', role: 'admin' },
]
/** The platform: IAM's rows for the two organizations, one conversation, and `{}` for the rest. Returns the writes. */
async function platform(page: Page): Promise<{ method: string; path: string; body: unknown }[]> {
const sent: { method: string; path: string; body: unknown }[] = []
await page.route(
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/oauth/') && !u.pathname.startsWith('/v1/billing/'),
(route) => {
const req = route.request()
const path = new URL(req.url()).pathname
if (req.method() !== 'GET') sent.push({ method: req.method(), path, body: req.postDataJSON?.() ?? null })
if (path === '/v1/iam/organizations')
return route.fulfill({ json: { organizations: [{ name: 'antje', displayName: 'Antje', isPersonal: true }, { name: 'webby-ai', displayName: 'Webby AI', isPersonal: false }] } })
if (path === '/v1/account/orgs') return route.fulfill({ json: { org: 'acme', displayName: 'Acme', additional: true } })
if (path === '/v1/agents/chat/conversations')
return route.fulfill({ json: { conversations: [{ id: 't-1', title: 'Aurora', updatedAt: new Date(Date.now() - 120_000).toISOString() }] } })
if (path.endsWith('/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
return route.fulfill({ json: {} })
},
)
return sent
}
const column = (page: Page) => page.locator('[data-slot="app-column"]')
const edge = (page: Page, name: string) => page.getByRole('separator', { name })
const width = async (page: Page, sel: string) => (await page.locator(sel).first().boundingBox())!.width
/** The cookie banner, answered: it stands over the foot of the column until a visitor has chosen. */
async function agree(page: Page) {
await page.getByRole('dialog', { name: 'Cookie preferences' }).getByRole('button', { name: 'OK' }).click({ timeout: 5000 }).catch(() => {})
}
async function open(page: Page, path = '/', org = 'webby-ai') {
await signIn(page, { name: 'Antje', orgs: ORGS })
await page.addInitScript((o) => localStorage.getItem('hanzo_iam_current_org') || localStorage.setItem('hanzo_iam_current_org', o), org)
const sent = await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}${path}`)
await expect(page.getByRole('navigation', { name: 'Hanzo' })).toBeVisible({ timeout: 30_000 })
await agree(page)
return sent
}
test('the rail and the sidebar stand on the floor, and the content and the column beside it are rounded panels inset by one gutter', async ({ page }) => {
await open(page, '/chat/t-1')
await page.getByRole('button', { name: 'Show tabs' }).click()
await expect(page.locator('aside[data-slot="side"]')).toBeVisible()
const radius = (sel: string) => page.locator(sel).first().evaluate((n) => parseFloat(getComputedStyle(n).borderTopLeftRadius))
const box = async (sel: string) => (await page.locator(sel).first().boundingBox())!
// The window: the rail at its left edge, the sidebar beside it, both on the floor and neither a card.
for (const sel of ['[data-slot="rail"]', '[data-slot="app-column"]']) expect(await radius(sel), `${sel} stands on the floor`).toBe(0)
for (const sel of ['main[data-slot="pane"]', 'aside[data-slot="side"]']) expect(await radius(sel), `${sel} is rounded`).toBeGreaterThanOrEqual(12)
const [rail, side, pane, beside] = await Promise.all([box('[data-slot="rail"]'), box('[data-slot="app-column"]'), box('main[data-slot="pane"]'), box('aside[data-slot="side"]')])
expect(rail.x, 'the rail at the window’s left edge').toBe(0)
expect(side.x, 'the sidebar beside the rail').toBeGreaterThanOrEqual(rail.x + rail.width)
expect(pane.y, 'the content inset at the top').toBeGreaterThanOrEqual(8)
expect(pane.x - (side.x + side.width), 'a gutter between the sidebar and the content').toBeGreaterThanOrEqual(8)
expect(beside.x - (pane.x + pane.width), 'and between the content and the column beside it').toBeGreaterThanOrEqual(8)
expect(1440 - (beside.x + beside.width), 'and at the right').toBeGreaterThanOrEqual(8)
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth), 'nothing sideways').toBeLessThanOrEqual(0)
})
test('the column is sized by dragging, keying and double-clicking its edge, kept across a reload, and folds to a rail', async ({ page }) => {
await open(page)
const grip = edge(page, 'Resize sidebar')
await expect(grip).toHaveAttribute('aria-valuenow', '240')
const was = await width(page, '[data-slot="app-column"]')
// Drag it 80px wider.
const g = (await grip.boundingBox())!
await page.mouse.move(g.x + g.width / 2, g.y + 200)
await page.mouse.down()
for (let i = 1; i <= 8; i++) await page.mouse.move(g.x + g.width / 2 + i * 10, g.y + 200)
await page.mouse.up()
await expect.poll(() => width(page, '[data-slot="app-column"]')).toBeCloseTo(was + 80, -1)
await expect(grip).toHaveAttribute('aria-valuenow', '320')
expect(await page.evaluate(() => localStorage.getItem('hanzo.app.column'))).toBe('320')
// Kept, and drawn at that width from the first frame after a reload.
await page.reload()
await expect(page.getByRole('navigation', { name: 'Hanzo' })).toBeVisible({ timeout: 30_000 })
expect(await width(page, '[data-slot="app-column"]')).toBeCloseTo(320, 0)
// The keys a separator answers.
await edge(page, 'Resize sidebar').focus()
await page.keyboard.press('ArrowLeft')
await expect(edge(page, 'Resize sidebar')).toHaveAttribute('aria-valuenow', '312')
await page.keyboard.press('End')
await expect(edge(page, 'Resize sidebar')).toHaveAttribute('aria-valuenow', '420')
// A double-click puts it back.
await edge(page, 'Resize sidebar').dblclick()
await expect(edge(page, 'Resize sidebar')).toHaveAttribute('aria-valuenow', '240')
expect(await width(page, '[data-slot="app-column"]')).toBeCloseTo(240, 0)
// Pulled well past its floor, it shuts and the rail stands alone; opened from the rail's toggle, it comes back at its width.
const h = (await edge(page, 'Resize sidebar').boundingBox())!
await page.mouse.move(h.x + h.width / 2, h.y + 200)
await page.mouse.down()
for (let i = 1; i <= 12; i++) await page.mouse.move(h.x + h.width / 2 - i * 10, h.y + 200)
await page.mouse.up()
await expect(column(page)).toHaveCount(0)
await expect(page.getByRole('navigation', { name: 'Products' })).toBeVisible()
await page.getByRole('button', { name: 'Open sidebar' }).click()
await expect(edge(page, 'Resize sidebar')).toHaveAttribute('aria-valuenow', '240')
})
test('the sidebar’s toggle is the rail’s first mark, at the window’s top-left: it shuts the sidebar and opens it, kept across a reload', async ({ page }, info) => {
await open(page, '/chat/t-1')
const rail = page.getByRole('navigation', { name: 'Products' })
const toggle = page.locator('[data-slot="sidebar-toggle"]')
await expect(toggle).toHaveCount(1)
await expect(toggle).toHaveAccessibleName('Close sidebar')
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
const t = (await toggle.boundingBox())!
expect(t.x, 'at the left').toBeLessThan(24)
expect(t.y, 'at the top').toBeLessThan(24)
// The conversation's header carries no second one.
await expect(page.locator('main').locator('[data-slot="sidebar-toggle"]')).toHaveCount(0)
await toggle.click()
await expect(column(page)).toHaveCount(0)
await expect(toggle).toHaveAccessibleName('Open sidebar')
await expect(rail.getByRole('button', { name: 'Chat', exact: true })).toBeVisible()
// The pane takes the room, one gutter beside the rail.
const [r, p] = await Promise.all([rail.boundingBox(), page.locator('main[data-slot="pane"]').boundingBox()])
expect(p!.x - (r!.x + r!.width), 'one gutter between the rail and the pane').toBeLessThanOrEqual(16)
await page.screenshot({ path: info.outputPath('frame-1440-shut.png') })
expect(await page.evaluate(() => localStorage.getItem('hanzo.build.rail'))).toBe('true')
await page.reload()
await expect(rail).toBeVisible({ timeout: 30_000 })
await expect(column(page), 'shut is kept').toHaveCount(0)
await page.locator('[data-slot="sidebar-toggle"]').click()
await expect(column(page)).toBeVisible()
await expect(page.locator('[data-slot="sidebar-toggle"]')).toHaveAccessibleName('Close sidebar')
})
test('the column beside a conversation is sized by its edge, kept, and shut by pulling it past its floor', async ({ page }) => {
// The column is @hanzo/build's (`useSplit`): it opens at 480 and keeps the person's width in their
// preferences (PATCH /v1/pref `side.width`), which every Hanzo surface reads back (GET /v1/pref).
// Registered after the platform's, so it answers first (Playwright runs the newest route first).
await open(page, '/chat/t-1')
const kept: { prefs: Record<string, unknown> } = { prefs: {} }
await page.route(
(u) => u.pathname === '/v1/pref',
(route) => {
const req = route.request()
if (req.method() === 'PATCH') kept.prefs = { ...kept.prefs, ...(req.postDataJSON() as Record<string, unknown>) }
return route.fulfill({ json: { prefs: kept.prefs, updatedAt: new Date().toISOString() } })
},
)
await page.getByRole('button', { name: 'Show tabs' }).click()
const grip = edge(page, 'Resize side panel')
await expect(grip).toHaveAttribute('aria-valuenow', '480')
await grip.focus()
await page.keyboard.press('Shift+ArrowLeft')
await expect(grip).toHaveAttribute('aria-valuenow', '512')
expect(await width(page, 'aside[data-slot="side"]')).toBeCloseTo(512, 0)
await expect.poll(() => kept.prefs.side, { message: 'kept in the preferences' }).toEqual({ width: 512 })
await page.reload()
await expect(page.locator('aside[data-slot="side"]')).toBeVisible({ timeout: 30_000 })
await expect.poll(() => width(page, 'aside[data-slot="side"]'), { message: 'kept across a reload' }).toBeCloseTo(512, 0)
await edge(page, 'Resize side panel').dblclick()
await expect(edge(page, 'Resize side panel')).toHaveAttribute('aria-valuenow', '480')
await edge(page, 'Resize side panel').focus()
await page.keyboard.press('Home')
await page.keyboard.press('ArrowRight')
await expect(page.locator('aside[data-slot="side"]'), 'keyed past its floor, it shuts').toHaveCount(0)
})
test('on a phone the column is a menu behind the pane, the pane slides toward the end over it, and nothing scrolls sideways', async ({ page }) => {
await signIn(page, { name: 'Antje', orgs: ORGS })
await platform(page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/chat/t-1`)
await expect(page.locator('main[data-slot="pane"]')).toBeVisible({ timeout: 30_000 })
await expect(page.locator('[data-slot="app-column"]').first()).toBeHidden()
await page.getByRole('button', { name: 'Open sidebar' }).click()
const menu = page.locator('[data-slot="drawer"] [data-slot="menu"]')
const column = menu.locator('[data-slot="app-rail"]')
await expect(column).toBeVisible()
// The slide is animated, so it is measured where it rests.
const pane = page.locator('[data-slot="frame"]')
await expect
.poll(async () => {
const [a, b] = await Promise.all([menu.boundingBox(), pane.boundingBox()])
return Math.abs(b!.x - (a!.x + a!.width))
}, { message: 'the pane slid off the menu toward the end' })
.toBeLessThanOrEqual(1)
const m = (await menu.boundingBox())!
const p = (await pane.boundingBox())!
expect(m.x, 'the menu stands at the start edge').toBeLessThanOrEqual(1)
expect(390 - p.x, 'leaving a strip of itself in sight').toBeGreaterThanOrEqual(44)
// The column is the menu's floor, never a card cut round it; the pane is what is raised.
const cut = await column.evaluate((n) => {
const s = getComputedStyle(n)
return { round: parseFloat(s.borderTopLeftRadius), drop: s.boxShadow }
})
expect(cut, 'no card round the column').toEqual({ round: 0, drop: 'none' })
const raised = await pane.evaluate((n) => {
const s = getComputedStyle(n)
return { round: parseFloat(s.borderTopLeftRadius), drop: s.boxShadow }
})
expect(raised.round, 'the pane’s start corners round').toBeGreaterThanOrEqual(12)
expect(raised.drop, 'and it casts a drop on the menu').not.toBe('none')
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0)
})
/** The account's own page (@hanzo/app Me): the › on the menu's first row, which names who is signed in, opens it as a menu of that name. */
async function own(page: Page): Promise<Locator> {
const who = 'A Antje [email protected]'
await page.getByRole('menu', { name: 'Account' }).getByRole('menuitem', { name: who }).click()
const it = page.getByRole('menu', { name: who })
await expect(it).toBeVisible()
return it
}
test('the account row names the organization, and its menu switches, creates, and opens settings and Hanzo Team', async ({ page }) => {
const sent = await open(page)
// The account is the face at the rail's foot; its name says who and where.
const me = page.getByRole('navigation', { name: 'Products' }).getByRole('button', { name: 'Account: Antje · webby-ai' })
await expect(me).toBeVisible()
// The account's own page from the keyboard: → opens it on its first organization, and the keys move through the rows.
await me.click()
const menu = page.getByRole('menu', { name: 'Account' })
await menu.getByRole('menuitem', { name: 'A Antje [email protected]' }).focus()
await page.keyboard.press('ArrowRight')
const account = page.getByRole('menu', { name: 'A Antje [email protected]' })
const orgs = account.getByRole('menuitem', { name: /(Personal|Admin|Owner|Member)/ })
await expect(orgs.nth(0)).toBeFocused()
await page.keyboard.press('ArrowDown')
await expect(orgs.nth(1)).toBeFocused()
await page.keyboard.press('ArrowDown')
await expect(account.getByRole('menuitem', { name: 'Create organization' })).toBeFocused()
await page.keyboard.press('ArrowUp')
await expect(orgs.nth(1)).toBeFocused()
// The organizations, the personal one first, the one in use marked Current, then Hanzo Team.
await expect(orgs).toHaveCount(2)
await expect(orgs.nth(0)).toContainText('Antje')
await expect(orgs.nth(0)).toContainText('Personal')
await expect(orgs.nth(1)).toContainText('Webby AI')
await expect(orgs.nth(1)).toContainText('Admin')
await expect(orgs.nth(1).getByRole('img', { name: 'Current' })).toBeVisible()
await expect(account.getByRole('menuitem', { name: /Open in Hanzo Team/ })).toBeVisible()
// To the personal organization, and back.
await orgs.nth(0).click()
await expect(page.getByRole('button', { name: 'Account: Antje · antje' })).toBeVisible({ timeout: 30_000 })
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_current_org'))).toBe('antje')
await page.getByRole('button', { name: 'Account: Antje · antje' }).click()
const personal = await own(page)
await expect(personal.getByRole('menuitem', { name: 'Create a team' }), 'a personal org is offered a team').toBeVisible()
await personal.getByRole('menuitem', { name: /Webby AI/ }).click()
await expect(page.getByRole('button', { name: 'Account: Antje · webby-ai' })).toBeVisible({ timeout: 30_000 })
// Create organization: IAM's create, then the app in the new one.
await page.getByRole('button', { name: 'Account: Antje · webby-ai' }).click()
await (await own(page)).getByRole('menuitem', { name: 'Create organization' }).click()
const sheet = page.getByRole('dialog')
await sheet.getByRole('textbox', { name: 'Organization name' }).fill('Acme')
await sheet.getByRole('button', { name: 'Create', exact: true }).click()
// The app reloads in it. The fixture's token cannot carry the new organization, so the column says so plainly.
await expect(page.locator('[data-slot="made"]')).toContainText('Acme was created', { timeout: 30_000 })
expect(sent.find((s) => s.path === '/v1/account/orgs')?.body).toEqual({ name: 'Acme' })
expect(await page.evaluate(() => sessionStorage.getItem('hanzo:org:made'))).toContain('"org":"acme"')
await expect(page.getByRole('button', { name: 'Account: Antje · webby-ai' }), 'still working where it was').toBeVisible()
})
test('Organization settings opens /settings/organization and Hanzo Team opens in the same organization', async ({ page, context }) => {
await open(page)
await page.getByRole('button', { name: 'Account: Antje · webby-ai' }).click()
// A tab of its own, as Hanzo Team below (@hanzo/app Me opens both through help `out`), so the app stays.
const [settings] = await Promise.all([context.waitForEvent('page'), (await own(page)).getByRole('menuitem', { name: 'Organization settings' }).click()])
await expect.poll(() => new URL(settings.url()).pathname).toBe('/settings/organization')
expect(new URL(page.url()).pathname, 'the app stays where it was').toBe('/')
await settings.close()
await context.route('https://hanzo.team/**', (route) => route.fulfill({ contentType: 'text/html', body: '<title>Hanzo Team</title>' }))
await page.goto(`${base}/`)
await page.getByRole('button', { name: 'Account: Antje · webby-ai' }).click({ timeout: 30_000 })
// A tab of its own (@hanzo/app help `out`), so the app stays where it is.
const [team] = await Promise.all([context.waitForEvent('page'), (await own(page)).getByRole('menuitem', { name: /Open in Hanzo Team/ }).click()])
await expect.poll(() => team.url()).toContain('hanzo.team')
const to = new URL(team.url())
expect(to.searchParams.has('signin'), 'through the normal sign-in').toBe(true)
expect(to.searchParams.get('org'), 'in the same organization').toBe('webby-ai')
})