Files
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

1246 lines
70 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 { app, place, talk as conversation } from '@hanzo/app/lib/host'
/**
* SIGNED IN, `/` IS THE APP, in two modes with one column.
*
* hanzo.ai's `/` is the landing for a stranger and the web app for a member, in
* two modes the switch at the top right of the column moves between: Chat, a
* conversation, which is where the app opens, and Dev, @hanzo/build's builder.
* The column is the same in both — the wordmark and the switch on one row, New,
* Dev's places, the Recents of the mode it is in (Chat's conversations, Dev's
* runs, never both) with a filter by words and a view menu, and the account.
* Chat asks for no codebase; a Dev run works on a project, which is a
* repository: the org's on the forge, listed under Projects.
*
* The address says the mode, as a path (@hanzo/app lib/host `talk`, `app`,
* `place`): a path under `/dev` is Dev — `/dev/projects/<org>/<repo>` a
* project, `/dev/run/<id>` a run — and `/`, `/chat` and `/chat/<id>` are Chat,
* on a new conversation or one of the reader's. Every kind survives a reload
* and steps back on Back, and an old address (`/?chat=`, `/?at=`, `/dev?run=`,
* the builder's grammar under /dev) moves to its path once.
*
* On the shipped bytes, with the platform answered here: one run, one
* conversation, a streamed answer, the org's repositories, and `{}` for every
* other read, so what is measured is where the app sits and what the address
* holds.
*/
const RUN = `sess_${'0123456789abcdef'.repeat(2)}`
const NEXT = `sess_${'fedcba9876543210'.repeat(2)}`
const TITLE = 'Add the widget'
const THREAD = '1790205245190697166-000014'
const TOPIC = 'Aurora'
const MINTED = '1790205245190697166-000099'
const ASKED = 'hello there'
const ORG = 'hanzo'
/** The org's repositories on the forge: circle has a site of the same name, which nothing has published. */
const REPOS = [
{ id: 'repo_1', org: ORG, name: 'circle', description: 'Social platform UI — feed, profile and messaging screens.', defaultBranch: 'main', cloneUrl: `https://api.hanzo.ai/v1/git/${ORG}/circle.git`, updatedAt: '2026-10-08T18:50:44Z' },
{ id: 'repo_2', org: ORG, name: 'hanzo-platform', defaultBranch: 'main', cloneUrl: `https://api.hanzo.ai/v1/git/${ORG}/hanzo-platform.git`, updatedAt: '2026-10-05T02:08:46Z' },
]
const SITES = [{ slug: 'circle', name: 'Circle', status: 'draft', forkedFrom: 'circle', repo: { url: 'https://github.com/hanzo-apps/template-circle', branch: 'main' }, createdAt: 1, updatedAt: 1 }]
const SAID = 'Hello from the gateway.'
/** A streamed answer, one frame a character, as the gateway sends one. */
const STREAM =
[...SAID].map((c) => `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: c } }] })}\n\n`).join('') + 'data: [DONE]\n\n'
type Sent = { method: string; path: string; body: unknown }
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
/** The platform, as this page reads it; every write lands in the list it answers. `stream` is what a completion answers with. */
async function platform(page: Page, stream = STREAM): Promise<Sent[]> {
const sent: Sent[] = []
let recorded = false
let started = false
await page.route(
// The plan is the session fixture's (../session): a reader who pays.
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/') && u.pathname !== '/v1/billing/tier' && u.pathname !== '/v1/billing/balance',
(route) => {
const req = route.request()
const url = new URL(req.url())
let body: unknown = null
try {
body = req.postDataJSON()
} catch {
body = req.postData()
}
if (req.method() !== 'GET') sent.push({ method: req.method(), path: url.pathname, body })
if (url.pathname.endsWith('/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
const run = { id: RUN, title: TITLE, status: 'running', kind: 'coding', createdAt: '2026-09-27T10:00:00Z', recentEvents: [] }
const next = { id: NEXT, title: 'new project: Add a login page', status: 'running', kind: 'coding', createdAt: '2026-09-28T10:00:00Z', recentEvents: [] }
if (url.pathname === '/v1/agent/sessions') return route.fulfill({ json: { sessions: started ? [next, run] : [run] } })
if (url.pathname === `/v1/agent/sessions/${RUN}`) return route.fulfill({ json: run })
if (url.pathname === `/v1/agent/sessions/${NEXT}`) return route.fulfill({ json: next })
if (url.pathname === '/v1/agent/coding') {
started = true
return route.fulfill({ status: 202, json: { sessionId: NEXT, repo: 'add-a-login-page', project: 'add-a-login-page', branch: '' } })
}
if (url.pathname === '/v1/chat/completions') return route.fulfill({ status: 200, contentType: 'text/event-stream', body: stream })
if (url.pathname === '/v1/git/repos') return route.fulfill({ json: { data: REPOS } })
const repo = REPOS.find((r) => url.pathname === `/v1/git/repos/${r.name}`)
if (repo) return route.fulfill({ json: { ...repo, branches: ['main'] } })
if (url.pathname.startsWith('/v1/git/repos/')) return route.fulfill({ status: 404, json: { status: 404, detail: 'empty repository' } })
if (url.pathname === '/v1/projects') return route.fulfill({ json: SITES })
if (url.pathname === '/v1/agents/chat/conversations' && req.method() === 'POST') {
recorded = true
return route.fulfill({ json: { conversationId: MINTED } })
}
if (url.pathname === '/v1/agents/chat/conversations') {
const old = { id: THREAD, title: TOPIC, updatedAt: new Date(Date.now() - 60_000).toISOString() }
return route.fulfill({ json: { conversations: recorded ? [{ id: MINTED, title: ASKED, updatedAt: new Date().toISOString() }, old] : [old] } })
}
if (url.pathname === `/v1/agents/chat/conversations/${THREAD}`)
return route.fulfill({ json: { id: THREAD, messages: [{ role: 'user', content: 'first words' }, { role: 'assistant', content: 'an answer' }] } })
if (url.pathname === `/v1/agents/chat/conversations/${MINTED}`)
return route.fulfill({ json: { id: MINTED, messages: recorded ? [{ role: 'user', content: ASKED }, { role: 'assistant', content: SAID }] : [] } })
return route.fulfill({ json: {} })
},
)
return sent
}
/**
* The chat's own completions. The suggestions under the composer ask the gateway
* too, once a room opens, for rows in their `suggestions` JSON schema; they are
* not the message. Told apart by that schema, not by their prompt's wording,
* which @hanzo/app rewrites.
*/
const chats = (sent: Sent[]) =>
sent.filter((s) => {
if (s.path !== '/v1/chat/completions') return false
const format = (s.body as { response_format?: { json_schema?: { name?: string } } }).response_format
return format?.json_schema?.name !== 'suggestions'
})
/** The app's own column, open beside the pane or as the drawer below md. */
const rail = (page: Page) => page.getByRole('navigation', { name: 'Hanzo' })
/** The rail of products at the window's edge, or below md the menu's rows for the places the bar does not carry. */
const products = (page: Page) => page.getByRole('navigation', { name: 'Products' })
/** A product's mark on the rail: Chat or Dev. */
const mode = (page: Page, name: string) => products(page).getByRole('button', { name, exact: true })
/** The column's top row: the mode's name and the search. */
const top = (page: Page) => rail(page).locator('[data-slot="app-head"]')
/** The Recents of the mode the column is in. */
const recents = (page: Page) => rail(page).getByRole('list', { name: 'Recents' })
/** Dev's builder address the path names, or null when the app is in Chat; and Chat's conversation. */
const at = (page: Page): string | null => {
const p = place(new URL(page.url()).pathname)
return p?.mode === 'dev' ? p.at : null
}
const chat = (page: Page): string | null => {
const p = place(new URL(page.url()).pathname)
return p?.mode === 'chat' ? p.thread : null
}
/** Chat's composer. */
const talk = (page: Page) => page.locator('main textarea').first()
/**
* What a layout gets wrong at a size: the page scrolling sideways, or something
* cut off at the window's edge with nothing that scrolls between. Text meant not
* to wrap and a row of tabs may scroll in a box of their own that fits; a mask
* inside that box (a round face) is part of the row it scrolls with.
*/
async function cramped(page: Page): Promise<string[]> {
return page.evaluate(() => {
const w = window.innerWidth
const out: string[] = []
const name = (el: Element) => `${el.tagName} ${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 40)}`
const wide = document.documentElement.scrollWidth
if (wide > w + 1) out.push(`the page is ${wide}px wide`)
const within = (r: DOMRect) => r.left >= -1 && r.right <= w + 1
const scrolls = (el: Element) => {
const pre = /^(pre|nowrap)/.test(getComputedStyle(el).whiteSpace)
for (let a = el.parentElement; a; a = a.parentElement) {
const s = getComputedStyle(a)
if (s.overflowX === 'visible' || s.overflowX === 'hidden' || s.overflowX === 'clip') continue
return (s.overflowX === 'auto' || s.overflowX === 'scroll') && (pre || s.flexDirection === 'row') && within(a.getBoundingClientRect())
}
return false
}
const leaves = [...document.querySelectorAll('body *')].filter(
(el) =>
!(el instanceof SVGElement && el.ownerSVGElement) &&
(!el.children.length || ['BUTTON', 'INPUT', 'svg'].includes(el.tagName) || el.getAttribute('role') === 'button'),
)
for (const el of leaves) {
const r = el.getBoundingClientRect()
if (!r.width || !r.height || !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue
if (!within(r) && !scrolls(el)) out.push(`${name(el)} is cut off`)
}
return out
})
}
test('signed out, `/` is the landing, and nothing of the app', async ({ page }, info) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
await expect(page.locator('header[data-slot="masthead"]')).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await page.screenshot({ path: info.outputPath('landing-1440.png') })
await expect(page.locator('[data-slot="app-rail"]')).toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.hasAttribute('data-app'))).toBe(false)
})
test('signed in, `/` is the app in Chat: the rail leads with Chat then Dev, and the sidebar has no switch', async ({ page }, info) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
await expect(talk(page)).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Build what’s next.')).toHaveCount(0)
await expect(mode(page, 'Chat')).toHaveAttribute('aria-current', 'page')
await expect(mode(page, 'Dev')).not.toHaveAttribute('aria-current', 'page')
await expect(rail(page).getByRole('button', { name: 'New chat', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(recents(page).getByRole('button', { name: TOPIC })).toBeVisible()
await expect(recents(page).getByRole('button', { name: TITLE }), 'a run is never a chat').toHaveCount(0)
// The switch is gone; the rail is how the app moves.
await expect(page.getByRole('group', { name: 'Switch Hanzo' })).toHaveCount(0)
await expect(page.locator('[data-slot="mode-switch"]')).toHaveCount(0)
// The rail, top to bottom: the sidebar's toggle, the products drawn in the app (Chat, Dev, Studio, and
// Tabs, which the app draws at /tabs), the places that take the sidebar (Library, Scheduled tasks,
// Customize), More, and the account at its foot. Dev's own places (Projects, Issues, Templates) are in
// Dev's sidebar, never on the rail.
const marks = await products(page).locator('[aria-label]').evaluateAll((all) => all.map((n) => n.getAttribute('aria-label')))
expect(marks.slice(0, 9)).toEqual(['Close sidebar', 'Chat', 'Dev', 'Studio', 'Tabs', 'Library', 'Scheduled tasks', 'Customize', 'More'])
for (const place of ['Projects', 'Issues', 'Templates']) expect(marks, `${place} is Dev's, not the rail's`).not.toContain(place)
expect(marks.at(-1)).toBe('Account: gate · hanzo')
const [edge, chatMark, devMark, me, column] = await Promise.all([
products(page).boundingBox(),
mode(page, 'Chat').boundingBox(),
mode(page, 'Dev').boundingBox(),
page.getByRole('button', { name: 'Account: gate · hanzo' }).boundingBox(),
rail(page).boundingBox(),
])
expect(edge!.x, 'the rail at the window’s left edge').toBeLessThan(24)
expect(edge!.x + edge!.width, 'the sidebar beside it').toBeLessThanOrEqual(column!.x)
expect(chatMark!.y, 'Chat first').toBeLessThan(devMark!.y)
expect(me!.y, 'the account at the foot').toBeGreaterThan(800)
// The sidebar's top row: the mode's name, then the search.
await expect(top(page).getByText('Hanzo AI', { exact: true })).toBeVisible()
await expect(top(page).getByRole('button', { name: 'Search' })).toBeVisible()
await expect(rail(page).getByRole('textbox', { name: 'Filter recents' }), 'the field opens from its mark').toHaveCount(0)
// More is a menu of the products on hosts of their own, then the apps in beta.
await products(page).getByRole('button', { name: 'More' }).click()
for (const item of ['Team', 'Betas']) await expect(page.getByRole('menuitem', { name: new RegExp(`^${item}`) })).toBeVisible()
await expect(page.getByRole('menuitem', { name: /^Tabs/ }), 'Tabs is on the rail, not behind More').toHaveCount(0)
await page.screenshot({ path: info.outputPath('app-1440-more.png') })
await page.keyboard.press('Escape')
// The conversation's header: icons alone at its right end, and no second sidebar toggle there.
const head = page.locator('[data-slot="conversation-controls"]')
await expect(head.getByRole('button', { name: 'Show tabs' })).toBeVisible()
await expect(head.getByRole('button', { name: 'Summary' })).toBeVisible()
await expect(page.locator('main').getByRole('button', { name: /sidebar/ })).toHaveCount(0)
await page.screenshot({ path: info.outputPath('app-1440-chat-default.png') })
// The site steps aside: no header, no docked composer.
await expect(page.locator('header[data-slot="masthead"]')).toBeHidden()
await expect(page.locator('[data-dock]')).toHaveCount(0)
})
test('signed in, `/dev` is Dev: New, the places, and the project its runs work on', async ({ page }, info) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/dev`)
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
await expect(mode(page, 'Dev')).toHaveAttribute('aria-current', 'page')
await expect(mode(page, 'Chat')).not.toHaveAttribute('aria-current', 'page')
await expect(top(page).getByText('Hanzo Dev', { exact: true })).toBeVisible()
await expect(top(page).getByText('Hanzo AI', { exact: true })).toHaveCount(0)
await expect(rail(page).getByRole('button', { name: 'New run', exact: true })).toHaveAttribute('aria-current', 'page')
// Dev's own places, in Dev's sidebar, with no group labels (@hanzo/app 0.1.85); the rail's places and
// Settings' sections are not listed there.
for (const place of ['Projects', 'Issues', 'Templates']) await expect(rail(page).getByText(place, { exact: true })).toBeVisible()
for (const away of ['Library', 'Scheduled tasks', 'Machines', 'Environments', 'Customize', 'Make', 'Run', 'Setup'])
await expect(rail(page).getByText(away, { exact: true })).toHaveCount(0)
await expect(recents(page).getByRole('button', { name: TITLE })).toBeVisible()
await expect(recents(page).getByRole('button', { name: TOPIC }), 'a chat is never a run').toHaveCount(0)
await expect(page.getByRole('button', { name: 'Account: gate · hanzo' })).toBeVisible()
// The composer is the first thing; the project it works on is chosen beside it.
await expect(page.getByRole('button', { name: 'Repository: Choose a project' })).toBeVisible()
// The site steps aside: no header, no docked composer.
await expect(page.locator('header[data-slot="masthead"]')).toBeHidden()
await expect(page.locator('[data-dock]')).toHaveCount(0)
await page.screenshot({ path: info.outputPath('app-1440-dev.png') })
})
test('the rail is a real control: a press or a key moves the mode, the pane and the address, and Back returns', async ({ page }, info) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
await expect(talk(page)).toBeVisible({ timeout: 20_000 })
const toChat = mode(page, 'Chat')
const toDev = mode(page, 'Dev')
expect(await toChat.evaluate((el) => el.tagName), 'a button, not a picture of one').toBe('BUTTON')
expect(await toDev.evaluate((el) => el.tagName)).toBe('BUTTON')
await toDev.click()
await expect.poll(() => new URL(page.url()).pathname).toBe('/dev')
expect(new URL(page.url()).search, 'no query rides along').toBe('')
await expect(toDev).toHaveAttribute('aria-current', 'page')
await expect(toChat).not.toHaveAttribute('aria-current', 'page')
await expect(rail(page).getByRole('button', { name: 'New run', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
await expect(rail(page).getByText('Templates', { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath('app-1440-dev.png') })
// By key, the way a keyboard reader moves.
await toChat.focus()
await page.keyboard.press('Enter')
await expect.poll(() => new URL(page.url()).pathname).toBe('/')
await expect(toChat).toHaveAttribute('aria-current', 'page')
await expect(talk(page)).toBeVisible()
await expect(page.getByText('Build what’s next.')).toHaveCount(0)
// Dev's places are Dev's.
await expect(rail(page).getByText('Templates', { exact: true })).toHaveCount(0)
// Back is Dev again, and forward is Chat.
await page.goBack()
await expect.poll(() => new URL(page.url()).pathname).toBe('/dev')
await expect(toDev).toHaveAttribute('aria-current', 'page')
await expect(page.getByText('Build what’s next.')).toBeVisible()
await page.goForward()
await expect(toChat).toHaveAttribute('aria-current', 'page')
})
test('a first message in Chat needs no codebase: it is sent, the answer streams back, and it lands in Recents and the address', async ({ page }) => {
await signIn(page)
const sent = await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
const field = talk(page)
await expect(field).toBeVisible({ timeout: 20_000 })
await field.fill(ASKED)
await field.press('Enter')
const room = page.locator('main')
await expect(room).toContainText(ASKED, { timeout: 20_000 })
await expect(room).toContainText(SAID, { timeout: 20_000 })
const asked = chats(sent)
expect(asked, 'the message went to the gateway').toHaveLength(1)
expect(JSON.stringify(asked[0].body)).toContain(ASKED)
// No codebase was asked for, and no run was started for it.
await expect(page.getByText(/codebase/i)).toHaveCount(0)
expect(sent.filter((s) => s.path === '/v1/agent/coding')).toHaveLength(0)
// Written down, it is the address and the open row of Recents.
await expect.poll(() => chat(page), { timeout: 20_000 }).toBe(MINTED)
await expect(recents(page).getByRole('button', { name: ASKED })).toHaveAttribute('aria-current', 'page')
// And it is still there after a reload, read back from the store.
await page.reload()
await expect(page.locator('main')).toContainText(SAID, { timeout: 20_000 })
expect(chat(page)).toBe(MINTED)
})
test('a Dev run works on a project: with none chosen the picker opens, and no project is made up from the ask', async ({ page }) => {
await signIn(page)
const sent = await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/dev`)
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('button', { name: 'Repository: Choose a project' })).toBeVisible()
const ask = page.getByRole('textbox', { name: 'Describe a task or ask a question' })
await ask.fill('Add a login page')
await ask.press('Enter')
await expect(page.getByText('Choose the project this run works on: a repository of this organization, or one on its GitHub.')).toBeVisible()
expect(sent.filter((s) => s.path === '/v1/agent/coding'), 'nothing is sent without a project').toHaveLength(0)
await page.getByRole('listbox').getByRole('option', { name: `${ORG}/circle` }).click()
await ask.press('Enter')
await expect.poll(() => at(page), { timeout: 20_000 }).toBe(NEXT)
const coding = sent.filter((s) => s.path === '/v1/agent/coding')
expect(coding).toHaveLength(1)
expect(coding[0].body).toMatchObject({ prompt: 'Add a login page', repo: 'circle', mode: 'build' })
// The new run heads Recents as the open one, without a reload.
await expect(recents(page).getByRole('button', { name: 'new project: Add a login page' })).toHaveAttribute('aria-current', 'page', { timeout: 20_000 })
})
test('an answer that comes back empty says so, and offers the question again', async ({ page }) => {
await signIn(page)
const sent = await platform(page, '')
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
const field = talk(page)
await expect(field).toBeVisible({ timeout: 20_000 })
await field.fill(ASKED)
await field.press('Enter')
await expect(page.locator('main')).toContainText('answered with nothing', { timeout: 20_000 })
await page.locator('main').getByRole('button', { name: 'Try again' }).click()
await expect.poll(() => chats(sent).length).toBe(2)
// Asked once again: the empty answer and the unanswered question are taken off first.
const again = (chats(sent)[1].body as { messages: { role: string; content: string }[] }).messages
expect(again.filter((m) => m.role === 'user' && m.content === ASKED)).toHaveLength(1)
expect(again.some((m) => m.role === 'assistant' && !m.content.trim())).toBe(false)
})
test('Recents: each mode lists its own, narrowed by words, arranged by a view kept per mode, and a row opens in its mode', async ({ page }, info) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
const list = recents(page)
// A row, not the options button a conversation's row carries beside it.
const rows = list.locator('[data-slot="sidebar-item"]')
// Chat lists its conversations and nothing of Dev's.
await expect(rows).toHaveText([TOPIC], { timeout: 20_000 })
await expect(list.getByRole('button', { name: TOPIC })).toHaveAttribute('data-kind', 'chat')
await expect(rail(page).getByRole('radiogroup', { name: 'Show' })).toHaveCount(0)
// The search mark opens the palette (@hanzo/app 0.1.83: the product's own, Chat's chats first), its field
// focused; a query narrows the chats it lists, and Escape puts it away with the sidebar's rows untouched.
const field = page.getByRole('combobox', { name: 'Search' })
await rail(page).getByRole('button', { name: 'Search' }).click()
await expect(field).toBeFocused()
await field.fill('AUR')
await expect(page.getByRole('listbox').getByRole('option', { name: TOPIC })).toBeVisible()
await field.fill('zzz')
await expect(page.getByText('Nothing matches “zzz”.')).toBeVisible()
await page.screenshot({ path: info.outputPath('recents-none.png') })
// Escape clears the query first and closes the palette on an empty field.
await field.press('Escape')
if (await field.isVisible()) await field.press('Escape')
await expect(field).toBeHidden()
await expect(rows).toHaveText([TOPIC])
// Chat's view menu: active or archived, last activity, grouped by date or none, sorted by activity or title.
const view = rail(page).getByRole('button', { name: 'Recents view' })
await view.click()
const menu = page.getByRole('menu')
for (const item of ['Active', 'Archived', 'Any time', 'Today', 'Date', 'None', 'Activity', 'Title']) await expect(menu.getByRole('menuitem', { name: item, exact: true })).toBeVisible()
await expect(menu.getByRole('menuitem', { name: 'Starred', exact: true })).toHaveCount(0)
// Plain is the reference's: Pinned, then one Recents, no dates; grouped by Date, the days come back.
await expect(list.getByRole('list', { name: 'Today' })).toHaveCount(0)
await menu.getByRole('menuitem', { name: 'Date', exact: true }).click()
await expect(list.getByRole('list', { name: 'Today' }).getByRole('button', { name: TOPIC })).toBeVisible()
// A pick leaves the view's choices open, the next one press away; Escape puts them away.
await expect(view).toHaveAttribute('aria-expanded', 'true')
await menu.getByRole('menuitem', { name: 'None', exact: true }).click()
await page.keyboard.press('Escape')
await expect(view).toHaveAttribute('aria-expanded', 'false')
await expect(list.getByRole('list', { name: 'Today' })).toHaveCount(0)
await expect(rows).toHaveText([TOPIC])
await page.screenshot({ path: info.outputPath('recents-chat-view.png') })
// A conversation opens in Chat, on itself.
await list.getByRole('button', { name: TOPIC }).click()
await expect.poll(() => chat(page)).toBe(THREAD)
await expect(page.locator('main')).toContainText('first words', { timeout: 20_000 })
await expect(list.getByRole('button', { name: TOPIC })).toHaveAttribute('aria-current', 'page')
// Dev lists its runs and nothing of Chat's, with its own view menu.
await mode(page, 'Dev').click()
await expect(rows).toHaveText([TITLE], { timeout: 20_000 })
await expect(list.getByRole('button', { name: TITLE })).toHaveAttribute('data-kind', 'dev')
await view.click()
await expect(menu.getByText('Status', { exact: true })).toBeVisible()
for (const item of ['Running', 'Failed', 'State', 'Project', 'Created']) await expect(menu.getByRole('menuitem', { name: item, exact: true })).toBeVisible()
await menu.getByRole('menuitem', { name: 'Done', exact: true }).click()
await page.keyboard.press('Escape')
await expect(rows).toHaveCount(0)
await expect(rail(page).getByText('Nothing in this view.')).toBeVisible()
await view.click()
await menu.getByRole('menuitem', { name: 'All', exact: true }).click()
await page.keyboard.press('Escape')
await expect(rows).toHaveText([TITLE])
// Chat kept its own view.
await page.reload()
await expect(rows).toHaveText([TITLE], { timeout: 20_000 })
await mode(page, 'Chat').click()
await expect(rows).toHaveText([TOPIC], { timeout: 20_000 })
await expect(list.getByRole('list', { name: 'Today' })).toHaveCount(0)
// A run opens in Dev, on itself.
await mode(page, 'Dev').click()
await list.getByRole('button', { name: TITLE }).click()
await expect.poll(() => at(page)).toBe(RUN)
await expect(mode(page, 'Dev')).toHaveAttribute('aria-current', 'page')
await expect(list.getByRole('button', { name: TITLE })).toHaveAttribute('aria-current', 'page')
})
type Kept = { id: string; title: string; updatedAt: string; pinned: boolean; archived: boolean }
/**
* The conversation store as hanzoai/agent v1.0.12 answers it: the list pinned
* first and archived apart (`?archived=true`), PATCH changing what its body
* names, DELETE taking the conversation for good. Registered after `platform`,
* so it answers these addresses first. `store.hold` keeps the next change's
* answer back until it settles, and `store.refuse` refuses the next change in
* its own words: what the rows show meanwhile is the change, shown at once.
*/
async function conversations(page: Page, titles: [string, number][]) {
const all: Kept[] = titles.map(([title, ago], i) => ({ id: `1790205245190697166-00010${i}`, title, updatedAt: new Date(Date.now() - ago).toISOString(), pinned: false, archived: false }))
const asked: { method: string; title: string; body: unknown }[] = []
const store = { hold: null as Promise<void> | null, refuse: '' }
await page.route(
(u) => u.pathname.startsWith('/v1/agents/chat/conversations'),
async (route) => {
const req = route.request()
const url = new URL(req.url())
const id = decodeURIComponent(url.pathname.split('/')[5] ?? '')
if (!id && req.method() === 'GET') {
const shelf = url.searchParams.get('archived') === 'true'
const rows = all.filter((c) => c.archived === shelf).sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt.localeCompare(a.updatedAt))
return route.fulfill({ json: { conversations: rows } })
}
const one = all.find((c) => c.id === id)
if (!one && req.method() !== 'GET') {
// Asked of a conversation that is gone: counted, and answered as the store does.
asked.push({ method: req.method(), title: '', body: null })
return route.fulfill({ status: 404, json: { detail: 'conversation not found' } })
}
if (!one) return route.fallback()
if (req.method() === 'PATCH' || req.method() === 'DELETE') {
const held = store.hold
store.hold = null
if (held) await held
if (store.refuse) {
const detail = store.refuse
store.refuse = ''
return route.fulfill({ status: 503, json: { detail } })
}
}
if (req.method() === 'PATCH') {
const body = req.postDataJSON() as Partial<Kept>
asked.push({ method: 'PATCH', title: one.title, body })
Object.assign(one, body)
return route.fulfill({ json: one })
}
if (req.method() === 'DELETE') {
asked.push({ method: 'DELETE', title: one.title, body: null })
all.splice(all.indexOf(one), 1)
return route.fulfill({ json: { id, deleted: true } })
}
return route.fulfill({ json: { conversationId: id, messages: [{ role: 'user', content: `about ${one.title}` }, { role: 'assistant', content: 'an answer' }] } })
},
)
/** Holds the next change's answer until the returned function is called. */
const hold = () => {
let go!: () => void
store.hold = new Promise<void>((resolve) => (go = resolve))
return go
}
return { all, asked, store, hold }
}
/** Every entry of a conversation's menu, in its order. */
const ITEMS = ['Rename', 'Pin', 'Share…', 'Copy link', 'Open in new tab', 'Archive', 'Delete']
for (const scheme of ['light', 'dark'] as const) {
test(`in ${scheme}, a conversation's menu: a right-click or its ⋯, every entry, its keys, each change shown at once`, async ({ page, context }, info) => {
test.setTimeout(180_000)
await signIn(page)
await platform(page)
const { all, asked, store, hold } = await conversations(page, [
['Aurora', 60_000],
['Borealis', 2 * 3_600_000],
['Comet', 3 * 86_400_000],
])
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.emulateMedia({ colorScheme: scheme })
await page.addInitScript((theme) => localStorage.setItem('hanzo.appearance', JSON.stringify({ theme })), scheme)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`)
await expect.poll(() => page.evaluate(() => document.documentElement.className)).toContain(`t_${scheme}`)
const list = recents(page)
const rows = list.locator('[data-slot="sidebar-item"]')
const row = (title: string) => list.locator('[data-thread]').filter({ has: page.locator('[data-slot="sidebar-item"]', { hasText: title }) })
const item = (title: string) => row(title).locator('[data-slot="sidebar-item"]')
const menu = page.getByRole('menu')
const id = (title: string) => all.find((c) => c.title === title)!.id
await expect(rows).toHaveText(['Aurora', 'Borealis', 'Comet'], { timeout: 20_000 })
// A right-click opens every entry at the cursor, with the keys for the two that have them.
const at = (await item('Borealis').boundingBox())!
await page.mouse.click(at.x + 40, at.y + at.height / 2, { button: 'right' })
await expect(menu.getByRole('menuitem')).toHaveText(ITEMS.map((one) => new RegExp(`^${one}`)))
await expect(menu.getByRole('menuitem', { name: /^Rename/ })).toContainText('Ctrl+Alt+R')
await expect(menu.getByRole('menuitem', { name: /^Pin/ })).toContainText('Ctrl+Alt+P')
await expect(menu.getByRole('separator')).toHaveCount(1)
// The keys are printed for the eye and named for assistive technology, not read as part of the entry's name.
await expect(menu.getByRole('menuitem', { name: 'Rename', exact: true })).toHaveAttribute('aria-keyshortcuts', 'Control+Alt+R')
await expect(menu.getByRole('menuitem', { name: 'Pin', exact: true })).toHaveAttribute('aria-keyshortcuts', 'Control+Alt+P')
// Named by its conversation; one colour in it, Delete's, and that one legible: 4.5:1 on the panel.
await expect(page.getByRole('menu', { name: 'Borealis' })).toBeVisible()
const tone = (name: RegExp) => menu.getByRole('menuitem', { name }).locator('span').first().evaluate((el) => getComputedStyle(el).color)
expect(await tone(/^Archive/)).toBe(await tone(/^Rename/))
expect(await tone(/^Delete/)).not.toBe(await tone(/^Rename/))
const contrast = await menu.evaluate((panel) => {
// The browser paints each colour, whatever space it is written in, over what is under it: the page's ground, the glass panel, the word.
const paint = (...colors: string[]) => {
const g = Object.assign(document.createElement('canvas'), { width: 1, height: 1 }).getContext('2d')!
for (const color of colors) {
g.fillStyle = color
g.fillRect(0, 0, 1, 1)
}
return [...g.getImageData(0, 0, 1, 1).data].slice(0, 3)
}
const lum = (rgb: number[]) =>
rgb.map((v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4)).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0)
const word = [...panel.querySelectorAll('[role="menuitem"]')].find((el) => el.textContent?.startsWith('Delete'))!.querySelector('span')!
const ground = getComputedStyle(document.documentElement).getPropertyValue('--background').trim() || '#000'
const under = [ground, getComputedStyle(panel).backgroundColor]
const [a, b] = [lum(paint(...under, getComputedStyle(word).color)), lum(paint(...under))]
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
})
expect(contrast, 'Delete on the panel').toBeGreaterThanOrEqual(4.5)
// Every ⋯ is its own element: one id each, none shared.
const ids = await list.locator('[data-slot="thread-options"]').evaluateAll((els) => els.map((el) => el.id))
expect(new Set(ids.filter(Boolean)).size).toBe(3)
await expect
.poll(async () => {
const box = (await menu.boundingBox())!
return Math.abs(box.x - (at.x + 40)) < 24 && Math.abs(box.y - (at.y + at.height / 2)) < 24
})
.toBe(true)
await page.screenshot({ path: info.outputPath(`menu-${scheme}.png`) })
// The keys walk it and Escape shuts it, giving focus back to the row.
await page.keyboard.press('Home')
await expect(menu.getByRole('menuitem', { name: /^Rename/ })).toBeFocused()
await page.keyboard.press('ArrowDown')
await expect(menu.getByRole('menuitem', { name: /^Pin/ })).toBeFocused()
await page.keyboard.press('Escape')
await expect(menu).toHaveCount(0)
await expect(item('Borealis')).toBeFocused()
// Pin lists it first, under Pinned, before the store has answered.
await item('Borealis').click({ button: 'right' })
let answer = hold()
await menu.getByRole('menuitem', { name: /^Pin/ }).click()
await expect(list.getByRole('list', { name: 'Pinned' }).locator('[data-slot="sidebar-item"]')).toHaveText(['Borealis'])
expect(asked).toEqual([])
answer()
await expect.poll(() => asked.at(-1)).toEqual({ method: 'PATCH', title: 'Borealis', body: { pinned: true } })
await expect(rows).toHaveText(['Borealis', 'Aurora', 'Comet'])
// The ⋯ opens the same menu from the keyboard, hung from itself; Enter on an entry chooses it.
const options = row('Comet').getByRole('button', { name: 'Chat options' })
await expect(options).toHaveAccessibleDescription('Comet')
await options.focus()
await expect(options).toHaveCSS('opacity', '1')
await page.keyboard.press('Enter')
await expect(menu.getByRole('menuitem')).toHaveText(ITEMS.map((one) => new RegExp(`^${one}`)))
// Hung under the ⋯ by its end, so it opens over the column rather than past it.
const dots = (await options.boundingBox())!
await expect
.poll(async () => {
const box = (await menu.boundingBox())!
return Math.abs(box.y - (dots.y + dots.height)) < 24 && Math.abs(box.x + box.width - (dots.x + dots.width)) < 24
})
.toBe(true)
await page.screenshot({ path: info.outputPath(`options-${scheme}.png`) })
await page.keyboard.press('Escape')
await expect(menu).toHaveCount(0)
await expect(options).toBeFocused()
await page.keyboard.press('Enter')
await expect(menu.getByRole('menuitem', { name: /^Rename/ })).toBeVisible()
await page.keyboard.press('Home')
await expect(menu.getByRole('menuitem', { name: /^Rename/ })).toBeFocused()
await page.keyboard.press('Enter')
const rename = page.getByRole('dialog', { name: 'Rename chat' })
await expect(rename.getByRole('textbox', { name: 'Chat title' })).toHaveValue('Comet')
await expect(rename.getByRole('textbox', { name: 'Chat title' })).toBeFocused()
await rename.getByRole('textbox', { name: 'Chat title' }).fill('Comet tails')
// The Enter that ends an IME composition writes the word, and saves nothing.
await rename.getByRole('textbox', { name: 'Chat title' }).dispatchEvent('keydown', { key: 'Enter', isComposing: true, bubbles: true })
await expect(rename).toBeVisible()
await page.screenshot({ path: info.outputPath(`rename-${scheme}.png`) })
answer = hold()
await rename.getByRole('button', { name: 'Save' }).click()
await expect(rename).toHaveCount(0)
await expect(rows).toHaveText(['Borealis', 'Aurora', 'Comet tails'])
answer()
await expect.poll(() => asked.at(-1)).toEqual({ method: 'PATCH', title: 'Comet', body: { title: 'Comet tails' } })
// The dialog gives focus back to the ⋯ it was opened from.
await expect(row('Comet tails').getByRole('button', { name: 'Chat options' })).toBeFocused()
// A refusal takes the change back and says why.
store.refuse = 'The conversation store is not answering.'
answer = hold()
await item('Aurora').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Archive', exact: true }).click()
await expect(rows).toHaveText(['Borealis', 'Comet tails'])
answer()
await expect(page.getByText('The conversation store is not answering.')).toBeVisible()
await expect(rows).toHaveText(['Borealis', 'Aurora', 'Comet tails'])
expect(all.find((c) => c.title === 'Aurora')!.archived).toBe(false)
// Archive takes it out of the list for good this time; the view's Archived lists it alone, and Unarchive brings it back.
await item('Aurora').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Archive', exact: true }).click()
await expect(rows).toHaveText(['Borealis', 'Comet tails'])
await expect.poll(() => asked.at(-1)).toEqual({ method: 'PATCH', title: 'Aurora', body: { archived: true } })
const view = rail(page).getByRole('button', { name: 'Recents view' })
await view.click()
await menu.getByRole('menuitem', { name: 'Archived', exact: true }).click()
await page.keyboard.press('Escape')
await expect(rows).toHaveText(['Aurora'])
await item('Aurora').click({ button: 'right' })
await expect(menu.getByRole('menuitem', { name: 'Unarchive', exact: true })).toBeVisible()
await menu.getByRole('menuitem', { name: 'Unarchive', exact: true }).click()
await expect(rail(page).getByText('No archived chats.')).toBeVisible()
await view.click()
await menu.getByRole('menuitem', { name: 'Active', exact: true }).click()
await page.keyboard.press('Escape')
await expect(rows).toHaveText(['Borealis', 'Aurora', 'Comet tails'])
// Copy link puts the conversation's own address on the clipboard.
await item('Borealis').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Copy link' }).click()
await expect(page.getByText('Link copied')).toBeVisible()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(`${base}${conversation(id('Borealis'))}`)
// Open in new tab, a ⌘/Ctrl-click and a middle-click each open it in a tab of its own, and leave this one where it was.
for (const open of ['menu', 'Control', 'middle'] as const) {
const opened = context.waitForEvent('page')
if (open === 'menu') {
await item('Borealis').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Open in new tab' }).click()
} else if (open === 'Control') await item('Borealis').click({ modifiers: ['ControlOrMeta'] })
else await item('Borealis').click({ button: 'middle' })
const tab = await opened
await expect.poll(() => tab.url()).toBe(`${base}${conversation(id('Borealis'))}`)
await tab.close()
expect(chat(page), `${open} left this tab where it was`).toBeNull()
}
// Share… opens the conversation's Share panel; Escape gives focus back to the row.
await item('Comet tails').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Share…' }).click()
const share = page.getByRole('dialog', { name: 'Share this chat' })
await expect(share.getByText('No links yet. Copy link makes one.')).toBeVisible()
await expect(share.getByRole('button', { name: 'Copy link' })).toBeVisible()
await page.screenshot({ path: info.outputPath(`share-${scheme}.png`) })
await page.keyboard.press('Escape')
await expect(share).toHaveCount(0)
await expect(item('Comet tails')).toBeFocused()
// Delete asks first: Cancel keeps it, Delete takes it at once.
for (const press of ['Cancel', 'Delete']) {
await item('Comet tails').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click()
const confirm = page.getByRole('dialog', { name: 'Delete chat?' })
await expect(confirm).toContainText('“Comet tails” is deleted for good')
if (press === 'Cancel') {
// An open dialog holds the keys: no rename replaces it, and nothing is pinned behind it.
const before = asked.length
await page.keyboard.press('Control+Alt+KeyR')
await page.keyboard.press('Control+Alt+KeyP')
await expect(page.getByRole('dialog', { name: 'Rename chat' })).toHaveCount(0)
await expect(confirm).toBeVisible()
expect(asked.length).toBe(before)
}
if (press === 'Delete') {
await page.screenshot({ path: info.outputPath(`delete-${scheme}.png`) })
answer = hold()
}
await confirm.getByRole('button', { name: press, exact: true }).click()
await expect(confirm).toHaveCount(0)
}
await expect(rows).toHaveText(['Borealis', 'Aurora'])
// Focus goes on to the row beside the one deleted, not to the page.
await expect(item('Aurora')).toBeFocused()
answer()
await expect.poll(() => asked.filter((a) => a.method === 'DELETE')).toEqual([{ method: 'DELETE', title: 'Comet tails', body: null }])
await page.waitForTimeout(500)
expect(asked.filter((a) => a.method === 'DELETE')).toHaveLength(1)
expect(all.map((c) => c.title)).toEqual(['Aurora', 'Borealis'])
// The keys act on the conversation the pane holds: Ctrl+Alt+P unpins it, Ctrl+Alt+R renames it.
await item('Borealis').click()
await expect.poll(() => chat(page)).toBe(id('Borealis'))
// The header's ⋯ opens the row's menu, which says Unpin while it is pinned.
const more = page.locator('[data-slot="conversation-controls"]').getByRole('button', { name: 'Conversation options' })
await more.click({ timeout: 20_000 })
await expect(menu.getByRole('menuitem', { name: 'Unpin', exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath(`header-menu-${scheme}.png`) })
await page.keyboard.press('Escape')
await expect(menu).toHaveCount(0)
// Held, the key repeats; it acts once.
const changes = asked.length
await page.keyboard.down('Control')
await page.keyboard.down('Alt')
for (let i = 0; i < 4; i++) await page.keyboard.down('KeyP')
await page.keyboard.up('KeyP')
await page.keyboard.up('Alt')
await page.keyboard.up('Control')
await expect(list.getByRole('list', { name: 'Pinned' })).toHaveCount(0)
await expect.poll(() => asked.length).toBe(changes + 1)
await page.waitForTimeout(500)
expect(asked.length).toBe(changes + 1)
await more.click()
await expect(menu.getByRole('menuitem', { name: 'Pin', exact: true })).toBeVisible()
await page.keyboard.press('Escape')
await expect.poll(() => asked.at(-1)).toEqual({ method: 'PATCH', title: 'Borealis', body: { pinned: false } })
// Never while typing: the same keys in the composer are the composer's.
await talk(page).focus()
await page.keyboard.press('Control+Alt+KeyR')
await expect(page.getByRole('dialog', { name: 'Rename chat' })).toHaveCount(0)
await item('Borealis').focus()
await page.keyboard.press('Control+Alt+KeyR')
await expect(page.getByRole('dialog', { name: 'Rename chat' }).getByRole('textbox', { name: 'Chat title' })).toHaveValue('Borealis')
await page.keyboard.press('Escape')
// With a row's menu open, the keys act on that row instead.
await item('Aurora').click({ button: 'right' })
await page.keyboard.press('Control+Alt+KeyP')
await expect(menu).toHaveCount(0)
await expect(list.getByRole('list', { name: 'Pinned' }).locator('[data-slot="sidebar-item"]')).toHaveText(['Aurora'])
await expect.poll(() => asked.at(-1)).toEqual({ method: 'PATCH', title: 'Aurora', body: { pinned: true } })
await item('Aurora').hover()
await page.screenshot({ path: info.outputPath(`recents-${scheme}.png`) })
// Deleting the open conversation moves the room to a new chat — unless the reader opened another before the store answered.
answer = hold()
await item('Borealis').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click()
// Pressed twice before the dialog has gone: the store is asked once.
await page.getByRole('dialog', { name: 'Delete chat?' }).getByRole('button', { name: 'Delete', exact: true }).dblclick()
await item('Aurora').click()
await expect.poll(() => chat(page)).toBe(id('Aurora'))
answer()
await expect.poll(() => asked.at(-1)).toEqual({ method: 'DELETE', title: 'Borealis', body: null })
await expect(page.locator('main')).toContainText('about Aurora')
expect(chat(page)).toBe(id('Aurora'))
await page.waitForTimeout(500)
expect(asked.filter((a) => a.method === 'DELETE').map((a) => a.title)).toEqual(['Comet tails', 'Borealis'])
// The one it holds, deleted with nothing opened since, moves the room to a new chat once the store has it.
await item('Aurora').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click()
await page.getByRole('dialog', { name: 'Delete chat?' }).getByRole('button', { name: 'Delete', exact: true }).click()
await expect.poll(() => asked.at(-1)).toEqual({ method: 'DELETE', title: 'Aurora', body: null })
await expect.poll(() => chat(page)).toBeNull()
await expect(rail(page).getByText('No chats yet.')).toBeVisible()
})
}
test.describe('on a touch screen', () => {
test.use({ hasTouch: true, isMobile: true, viewport: { width: 1280, height: 800 } })
test('every row shows its ⋯, a tap on it opens the menu, and so does a long-press on the row', async ({ page }, info) => {
await signIn(page)
await platform(page)
await conversations(page, [
['Aurora', 60_000],
['Borealis', 2 * 3_600_000],
])
await page.goto(`${base}/`)
const list = recents(page)
const menu = page.getByRole('menu')
await expect(list.locator('[data-slot="sidebar-item"]')).toHaveText(['Aurora', 'Borealis'], { timeout: 20_000 })
// No hover to find it by, so it is there on every row, and the age gives way.
for (const one of await list.getByRole('button', { name: 'Chat options' }).all()) {
await expect(one).toHaveCSS('opacity', '1')
// A thumb's 44px.
const box = (await one.boundingBox())!
expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(44)
}
// The title stops short of it.
for (const one of await list.locator('[data-slot="sidebar-item"]').all()) expect(parseFloat(await one.evaluate((el) => getComputedStyle(el).paddingRight))).toBeGreaterThanOrEqual(48)
await page.screenshot({ path: info.outputPath('touch-rows.png') })
const borealis = list.locator('[data-thread]').filter({ hasText: 'Borealis' })
await borealis.getByRole('button', { name: 'Chat options' }).tap()
await expect(menu.getByRole('menuitem')).toHaveText(ITEMS.map((one) => new RegExp(`^${one}`)))
await page.keyboard.press('Escape')
await expect(menu).toHaveCount(0)
// A long-press: held, not moved, past the 700 ms gui waits for.
const box = (await borealis.locator('[data-slot="sidebar-item"]').boundingBox())!
const touch = await page.context().newCDPSession(page)
const point = [{ x: box.x + 40, y: box.y + box.height / 2 }]
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: point })
await page.waitForTimeout(900)
await expect(menu.getByRole('menuitem')).toHaveText(ITEMS.map((one) => new RegExp(`^${one}`)))
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
// Letting go leaves the menu open and the conversation where it was.
await page.waitForTimeout(300)
await expect(menu).toBeVisible()
expect(chat(page)).toBeNull()
await page.screenshot({ path: info.outputPath('touch-menu.png') })
})
})
test('/chat lists conversations with the same menu', async ({ page }, info) => {
// The platform first, so the session's own answers (the plan /chat stands behind) are the ones read.
await platform(page)
await signIn(page)
const LONG = 'A conversation whose title runs on well past the end of its row'
const { asked } = await conversations(page, [
['Aurora', 60_000],
['Borealis', 2 * 3_600_000],
[LONG, 3 * 3_600_000],
['', 4 * 3_600_000],
])
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/chat`)
const row = (title: string) => page.locator('[data-thread]').filter({ hasText: title })
const menu = page.getByRole('menu')
await expect(row('Borealis')).toBeVisible({ timeout: 20_000 })
await row('Borealis').locator('[data-slot="sidebar-item"]').click({ button: 'right' })
await expect(menu.getByRole('menuitem')).toHaveText(ITEMS.map((one) => new RegExp(`^${one}`)))
await page.screenshot({ path: info.outputPath('chat-menu.png') })
await menu.getByRole('menuitem', { name: /^Pin/ }).click()
await expect(page.getByText('Pinned', { exact: true })).toBeVisible()
await expect.poll(() => asked.at(-1)).toEqual({ method: 'PATCH', title: 'Borealis', body: { pinned: true } })
await row('Borealis').getByRole('button', { name: 'Chat options' }).click()
await expect(menu.getByRole('menuitem', { name: /^Unpin/ })).toBeVisible()
await page.keyboard.press('Escape')
// A long title and its dates stop short of the ⋯ at the row's end.
const long = row(LONG)
await long.hover()
const dots = (await long.getByRole('button', { name: 'Chat options' }).boundingBox())!
// Where each run of text is drawn: its own box, cut by every box above it in the row that clips.
const drawn = await long.locator('[data-slot="sidebar-item"] span').evaluateAll((all) =>
all.map((s) => {
let right = s.getBoundingClientRect().right
for (let p = s.parentElement; p; p = p.parentElement) {
if (getComputedStyle(p).overflowX !== 'visible') right = Math.min(right, p.getBoundingClientRect().right)
if (p.matches('[data-slot="sidebar-item"]')) break
}
return right
}),
)
for (const right of drawn) expect(right, 'text under the ⋯').toBeLessThanOrEqual(dots.x + 1)
await page.screenshot({ path: info.outputPath('chat-long.png') })
// An untitled chat reads Untitled; saving that unchanged renames nothing.
const changes = asked.length
await row('Untitled').locator('[data-slot="sidebar-item"]').click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Rename', exact: true }).click()
const rename = page.getByRole('dialog', { name: 'Rename chat' })
await expect(rename.getByRole('textbox', { name: 'Chat title' })).toHaveValue('Untitled')
await rename.getByRole('button', { name: 'Save' }).click()
await expect(rename).toHaveCount(0)
await page.waitForTimeout(500)
expect(asked.length).toBe(changes)
})
test('stars from before pins were the server’s become pins once the store takes a pin, and are then forgotten', async ({ page }) => {
await signIn(page)
await platform(page)
const { all, asked, store } = await conversations(page, [
['Aurora', 60_000],
['Borealis', 2 * 3_600_000],
])
const STARS = 'hanzo.starred-threads'
await page.addInitScript(
([key, id]) => {
if (sessionStorage.getItem('seeded')) return
sessionStorage.setItem('seeded', '1')
localStorage.setItem(key, JSON.stringify([id]))
},
[STARS, all[1].id],
)
await page.setViewportSize({ width: 1440, height: 900 })
const list = recents(page)
const rows = list.locator('[data-slot="sidebar-item"]')
// A store without the route (agent v1.0.11) takes no pin: the stars wait for a later visit.
store.refuse = '404 page not found'
await page.goto(`${base}/`)
await expect(rows).toHaveText(['Aurora', 'Borealis'], { timeout: 20_000 })
await expect.poll(() => store.refuse).toBe('')
expect(await page.evaluate((key) => localStorage.getItem(key), STARS)).toBe(JSON.stringify([all[1].id]))
await expect(list.getByRole('list', { name: 'Pinned' })).toHaveCount(0)
// One that takes it pins each, once, and the browser's set is gone.
await page.reload()
await expect(list.getByRole('list', { name: 'Pinned' }).locator('[data-slot="sidebar-item"]')).toHaveText(['Borealis'], { timeout: 20_000 })
expect(asked).toEqual([{ method: 'PATCH', title: 'Borealis', body: { pinned: true } }])
expect(await page.evaluate((key) => localStorage.getItem(key), STARS)).toBeNull()
await page.reload()
await expect(rows).toHaveText(['Borealis', 'Aurora'], { timeout: 20_000 })
await page.waitForTimeout(500)
expect(asked).toHaveLength(1)
})
test('every kind of address is a path, survives a reload, and steps back', async ({ page }, info) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/dev`)
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
// Settings, from the account menu's Settings (@hanzo/app Me), once the cookie banner over the column's foot is
// answered: the site's one tree at /settings, never a place of Dev's (@hanzo/app lib/host `settings`).
await page.getByRole('dialog', { name: 'Cookie preferences' }).getByRole('button', { name: 'OK' }).click({ timeout: 5000 }).catch(() => {})
await page.getByRole('button', { name: 'Account: gate · hanzo' }).click()
await page.getByRole('menu', { name: 'Account' }).getByRole('menuitem', { name: 'Settings' }).click()
await expect.poll(() => new URL(page.url()).pathname).toBe('/settings')
await page.getByRole('navigation', { name: 'Settings' }).getByRole('link', { name: 'Usage', exact: true }).click()
await expect.poll(() => new URL(page.url()).pathname).toBe('/settings/organization/usage')
await page.reload()
await expect(page.getByText("What this organization's plan includes")).toBeVisible({ timeout: 20_000 })
expect(new URL(page.url()).pathname, 'a Settings page survives a reload').toBe('/settings/organization/usage')
await page.goto(`${base}/dev`)
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
// A Customize section, from its place on the rail (@hanzo/app 0.1.85) and its row in the column.
await mode(page, 'Customize').click()
await expect.poll(() => at(page)).toBe('-/customize')
const connectors = page.getByRole('navigation', { name: 'Hanzo AI' }).getByRole('button', { name: 'Connectors', exact: true })
await connectors.click()
await expect.poll(() => at(page)).toBe('-/customize/connectors')
await page.reload()
await expect(connectors).toHaveAttribute('aria-current', 'page', { timeout: 20_000 })
await expect(page.locator('main h1')).toHaveText('Connectors')
expect(at(page), 'a Customize section survives a reload').toBe('-/customize/connectors')
// A run, from Dev's Recents: Customize's column holds its own sections, and its Back returns to Dev's.
await page.getByRole('navigation', { name: 'Hanzo AI' }).getByRole('button', { name: 'Back' }).click()
await expect.poll(() => at(page)).toBe('')
await recents(page).getByRole('button', { name: TITLE }).click()
await expect.poll(() => at(page)).toBe(RUN)
await page.reload()
await expect(page.getByText('Build what’s next.')).toHaveCount(0)
await expect(page.getByText(TITLE).first()).toBeVisible({ timeout: 20_000 })
expect(at(page), 'a run survives a reload').toBe(RUN)
await page.screenshot({ path: info.outputPath('app-1440-run.png') })
// A conversation, from Chat's Recents.
await mode(page, 'Chat').click()
await recents(page).getByRole('button', { name: TOPIC }).click()
await expect.poll(() => chat(page)).toBe(THREAD)
await page.reload()
await expect(page.locator('main')).toContainText('first words', { timeout: 20_000 })
expect(chat(page), 'a conversation survives a reload').toBe(THREAD)
// Back steps through them, and the app follows the address: the switch to Chat, then the run.
await page.goBack()
await expect.poll(() => chat(page)).toBe(null)
await page.goBack()
await expect.poll(() => at(page)).toBe(RUN)
await expect(mode(page, 'Dev')).toHaveAttribute('aria-current', 'page')
await page.goBack()
await expect.poll(() => at(page)).toBe('')
await page.goBack()
await expect.poll(() => at(page)).toBe('-/customize/connectors')
await expect(connectors).toHaveAttribute('aria-current', 'page')
await page.goBack()
await expect.poll(() => at(page)).toBe('-/customize')
await expect(page.locator('main h1')).toHaveText('Agents')
await page.goForward()
await expect.poll(() => at(page)).toBe('-/customize/connectors')
// A project and a site arrive by address alone; the plans' address leaves for the pay site (plans.spec.ts).
await page.goto(`${base}${app(`${ORG}/circle`)}`)
await expect(page.getByRole('button', { name: 'Project: Circle' })).toBeVisible({ timeout: 20_000 })
await page.goto(`${base}${app('my-site')}`)
await expect.poll(() => at(page)).toBe('my-site')
// Anything that is not an address is New.
await page.goto(`${base}/dev/a/b/c`)
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
})
test('an address from before the app had paths moves to its path, once', async ({ page }) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
for (const [old, now, named] of [
['/?at=', '/dev', 'Dev'],
// A builder Settings section is the site's page, outside the app: no rail, nothing lit.
['/?at=-/settings/usage', '/settings/organization/usage', null],
[`/?at=${ORG}/circle`, app(`${ORG}/circle`), 'Dev'],
// Customize is a place on the rail (@hanzo/app 0.1.85), so its own mark is lit.
['/dev?at=-/customize', app('-/customize'), 'Customize'],
[`/dev?run=${RUN}`, app(RUN), 'Dev'],
['/dev?project=my-site', app('my-site'), 'Dev'],
['/dev?view=templates', app('-/templates'), 'Dev'],
// The builder's own grammar, as @hanzo/app 0.1.35–0.1.38 wrote it under /dev.
[`/dev/${RUN}`, app(RUN), 'Dev'],
['/dev/-/settings/usage', '/settings/organization/usage', null],
[`/dev/${ORG}/circle`, app(`${ORG}/circle`), 'Dev'],
// A conversation, as `/` and /chat carried it before it had a path.
[`/?chat=${THREAD}`, conversation(THREAD), 'Chat'],
[`/chat?thread=${THREAD}`, conversation(THREAD), 'Chat'],
] as const) {
await page.goto(`${base}/robots.txt`)
const before = await page.evaluate(() => history.length)
await page.goto(`${base}${old}`)
await expect.poll(() => `${new URL(page.url()).pathname}${new URL(page.url()).search}`, { message: old, timeout: 20_000 }).toBe(now)
if (named) await expect(mode(page, named)).toHaveAttribute('aria-current', 'page', { timeout: 20_000 })
else await expect(products(page).locator('[aria-current="page"]')).toHaveCount(0, { timeout: 20_000 })
// One entry for the old address and the new together: Back does not land on the old one.
expect(await page.evaluate(() => history.length), old).toBe(before + 1)
}
})
test('Projects is the organization’s repositories, and one opens at /dev/projects/<org>/<repo>: reload and Back keep it', async ({ page }, info) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/dev`)
await rail(page).getByText('Projects', { exact: true }).click()
await expect.poll(() => at(page)).toBe('-/projects')
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible({ timeout: 20_000 })
await expect(page.getByRole('button', { name: 'Link a GitHub repo' }).first()).toBeVisible()
const circle = page.getByRole('button', { name: `Open ${ORG}/circle` })
await expect(circle).toContainText('Social platform UI')
await expect(circle).toContainText('main')
await expect(page.getByRole('button', { name: `Open ${ORG}/hanzo-platform` })).toBeVisible()
await page.getByLabel('Find a project').fill('hanzo-plat')
await expect(circle).toHaveCount(0)
await page.getByLabel('Find a project').fill('')
await page.screenshot({ path: info.outputPath('dev-projects.png') })
await circle.click()
await expect.poll(() => at(page)).toBe(`${ORG}/circle`)
await expect(page.getByRole('button', { name: 'Project: Circle' })).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Nothing deployed yet')).toBeVisible()
await page.reload()
await expect(page.getByRole('button', { name: 'Project: Circle' })).toBeVisible({ timeout: 20_000 })
expect(at(page), 'a project survives a reload').toBe(`${ORG}/circle`)
await page.goBack()
await expect.poll(() => at(page)).toBe('-/projects')
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible({ timeout: 20_000 })
await page.goForward()
await expect(page.getByRole('button', { name: 'Project: Circle' })).toBeVisible({ timeout: 20_000 })
})
test('Chat’s pinned summary stays Chat’s: Dev draws no column beside its pane', async ({ page }) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 1913, height: 1000 })
await page.goto(`${base}/`)
await expect(talk(page)).toBeVisible({ timeout: 20_000 })
await page.getByRole('button', { name: 'Show tabs' }).click()
const side = page.locator('aside[data-slot="side"]')
await expect(side).toBeVisible()
await mode(page, 'Dev').click()
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
await expect(side).toHaveCount(0)
await rail(page).getByText('Projects', { exact: true }).click()
await page.getByRole('button', { name: `Open ${ORG}/circle` }).click()
await expect.poll(() => at(page)).toBe(`${ORG}/circle`)
await expect(page.getByRole('button', { name: 'Project: Circle' })).toBeVisible({ timeout: 20_000 })
await expect(side).toHaveCount(0)
// The project's pane takes the frame's whole width beside the column.
const [stage, frame] = await Promise.all([page.locator('[data-slot="stage"]').boundingBox(), page.locator('[data-slot="frame"]').boundingBox()])
expect(frame!.x + frame!.width - (stage!.x + stage!.width), 'only the gutter right of the pane').toBeLessThan(40)
// Back in Chat, the summary it pinned is there still.
await mode(page, 'Chat').click()
await expect(side).toBeVisible()
})
test('on a phone the menu stands behind the pane the bar slides away: its name and search, the places, Recents, and the account and Chat at its foot', async ({ page }, info) => {
await signIn(page)
await platform(page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/`)
await expect(talk(page)).toBeVisible({ timeout: 20_000 })
await expect(rail(page)).toHaveCount(0)
await expect(products(page), 'no rail at a phone’s edge').toHaveCount(0)
const bar = page.locator('[data-slot="app-bar"]')
// The phone's bar: the menu's toggle and the products as one control, Chat chosen; the brand is the menu's.
await expect(bar.getByRole('radiogroup', { name: 'App' })).toBeVisible()
await expect(bar.getByRole('radio', { name: 'Chat' })).toBeChecked()
await bar.getByRole('button', { name: 'Open sidebar' }).click()
await expect(rail(page)).toBeVisible()
await expect(page.getByRole('group', { name: 'Switch Hanzo' })).toHaveCount(0)
await expect(top(page).getByText('Hanzo AI', { exact: true })).toBeVisible()
await expect(top(page).getByRole('button', { name: 'Search' })).toBeVisible()
await expect(recents(page).getByRole('button', { name: TOPIC })).toBeVisible()
// The places the bar does not carry, as rows: Chat and Dev are the bar's.
await expect(products(page).getByRole('button')).toHaveText(['Studio', 'Tabs', 'More'])
// THE MENU STANDS BEHIND, at the start edge; the pane slides toward the end
// over it and a strip of it stays in sight, which is the press that closes.
// The slide is animated, so it is measured where it rests.
const boxes = () =>
Promise.all(['[data-slot="menu"]', '[data-slot="frame"]', '[data-slot="drawer-close"]'].map((s) => page.locator(s).boundingBox()))
await expect
.poll(async () => {
const [m, p] = await boxes()
return Math.abs(p!.x - (m!.x + m!.width))
}, { message: 'the pane slid to the menu’s end' })
.toBeLessThanOrEqual(1)
const [menu, pane, strip] = await boxes()
expect(menu!.x, 'the menu at the start edge').toBeLessThanOrEqual(1)
expect(390 - pane!.x, 'a strip of the pane in sight').toBeGreaterThanOrEqual(44)
expect(Math.abs(strip!.x - pane!.x) + Math.abs(strip!.width - (390 - pane!.x)), 'the strip is the pane in sight').toBeLessThanOrEqual(2)
// The foot: the account, and the accent New chat.
const foot = page.locator('[data-slot="drawer-foot"]')
await expect(foot.getByRole('button', { name: 'Account: gate · hanzo' })).toBeVisible()
const chat = foot.getByRole('button', { name: 'New chat' })
await expect(chat).toHaveText('Chat')
const [ground, accent] = await chat.evaluate((n) => {
const probe = document.createElement('div')
probe.style.backgroundColor = 'var(--accent)'
document.body.append(probe)
const want = getComputedStyle(probe).backgroundColor
probe.remove()
return [getComputedStyle(n).backgroundColor, want]
})
expect(ground, 'Chat wears the accent').toBe(accent)
const f = (await foot.boundingBox())!
expect(menu!.y + menu!.height - (f.y + f.height), 'at the menu’s foot').toBeLessThan(24)
await page.screenshot({ path: info.outputPath('app-390-drawer.png') })
// Nothing in the menu leaves it, and the page does not scroll sideways.
const out = await page.locator('[data-slot="menu"]').evaluate((m) => {
const edge = m.getBoundingClientRect().right
return [...m.querySelectorAll('*')]
.filter((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.height > 0 && (r.left < -1 || r.right > edge + 1)
})
.map((el) => `${el.tagName} ${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 40)}`)
})
expect(out, 'the menu at 390').toEqual([])
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0)
// The strip slides it home.
await page.locator('[data-slot="drawer-close"]').click()
await expect(rail(page), 'the strip puts the menu away').toHaveCount(0)
// Dev on the bar moves the app; a pick from the menu's Recents moves it and puts the menu away.
await bar.getByRole('radio', { name: 'Dev' }).click()
await expect.poll(() => new URL(page.url()).pathname).toBe('/dev')
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
await bar.getByRole('button', { name: 'Open sidebar' }).click()
await recents(page).getByRole('button', { name: TITLE }).click()
await expect.poll(() => at(page)).toBe(RUN)
await expect(rail(page), 'a pick puts the menu away').toHaveCount(0)
})
for (const size of [
{ width: 1920, height: 1080 },
{ width: 1366, height: 768 },
{ width: 820, height: 1180 },
{ width: 390, height: 844 },
]) {
test(`at ${size.width}px nothing in the app leaves the window`, async ({ page }, info) => {
await signIn(page)
await platform(page)
await page.setViewportSize(size)
for (const [where, ready] of [
['', () => talk(page)],
['dev', () => page.getByText('Build what’s next.')],
['dev/projects', () => page.getByRole('heading', { name: 'Projects' })],
[`dev/projects/${ORG}/circle`, () => page.getByRole('button', { name: 'Project: Circle' })],
// Customize's tabs are the sidebar's rows (@hanzo/app 0.1.85); the page heads itself with the tab.
['dev/customize/connectors', () => page.getByRole('heading', { name: 'Connectors' })],
] as const) {
await page.goto(`${base}/${where}`)
await expect(ready()).toBeVisible({ timeout: 20_000 })
await page.waitForTimeout(400)
await page.screenshot({ path: info.outputPath(`app-${size.width}-${where.replace(/[^a-z]+/g, '-') || 'chat'}.png`) })
expect(await cramped(page), `${where || 'Chat'} at ${size.width}`).toEqual([])
}
})
}