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.
1246 lines
70 KiB
TypeScript
1246 lines
70 KiB
TypeScript
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([])
|
||
}
|
||
})
|
||
}
|