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.
317 lines
18 KiB
TypeScript
317 lines
18 KiB
TypeScript
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')
|
||
})
|