mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
hanzo.ai's / (the app), /chat and /dev drew two looks — a floating column with a Chat / Dev switch, and the workspace's top bar with a flat sidebar. Every one of them is now @hanzo/rooms 0.1.58's App, ChatGPT desktop's shape: the rail (Chat, Dev, Library, Scheduled tasks, Customize, More, the account at its foot), the sidebar (the name, the bell, the search, New, Pinned and Recents), and the conversation in one rounded panel with its title, Share, its menu, the Summary and the tabs at the head. The account menu is the reference's: usage, the organization and its switcher, API keys on the platform, Settings, Help, and a plans row named Upgrade plan, Agency plan or Enterprise plan by the plan the organization is on. @hanzo/build 0.2.54. The gates read the rail where they read the switch, the account menu's organization and usage pages where its rows were one list, Recents plain by default, and a team's spaces as channels. Co-authored-by: Hanzo Dev <[email protected]>
138 lines
6.5 KiB
TypeScript
138 lines
6.5 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
|
import { rail } from './rail'
|
|
import { serveExport } from './export'
|
|
import { signIn, answer } from '../session'
|
|
|
|
/**
|
|
* THE SHELL IS THREE PANES: the rail of places down the left, the list of what
|
|
* the place holds, and the pane. The list leads with the Chat / Dev switch, then
|
|
* the places, Settings, the conversations, and the room's own lists — at Home its
|
|
* rooms, people and agents. On a phone the rail lies along the bottom with four
|
|
* places and More, and the list is a sheet the bar opens. /chat is the app
|
|
* (app.spec.ts), not a room of the shell.
|
|
*
|
|
* Proved on the export, signed in, with the roster answered: the picture is the
|
|
* evidence, held against a baseline, and the geometry is measured beside it.
|
|
*/
|
|
|
|
/** Places whose glyph and label overlap, by name. */
|
|
async function stacked(page: import('@playwright/test').Page): Promise<string[]> {
|
|
return page.evaluate(() =>
|
|
[...(document.querySelector('[data-slot="rail"]')?.querySelectorAll('button') ?? [])]
|
|
.filter((b) => b.getBoundingClientRect().width > 0)
|
|
.filter((b) => {
|
|
const glyph = b.querySelector('svg')
|
|
const label = b.querySelector('span')
|
|
return glyph && label && glyph.getBoundingClientRect().bottom > label.getBoundingClientRect().top
|
|
})
|
|
.map((b) => (b.textContent ?? '').trim() || (b.getAttribute('aria-label') ?? '?')),
|
|
)
|
|
}
|
|
|
|
const ROOMS = {
|
|
rooms: [
|
|
{ id: 'r1', space: 'acme', name: 'general', direct: false, private: false, archived: false, members: [] },
|
|
{ id: 'r2', space: 'acme', name: 'Ada', direct: true, private: true, archived: false, members: [] },
|
|
],
|
|
}
|
|
// The client reads `body.agents` off this route — a bare array answers with none.
|
|
const AGENTS = { agents: [{ id: 'a1', name: 'enso', model: 'enso' }] }
|
|
const CHATS = { conversations: [{ id: 't1', title: 'Aurora', updatedAt: new Date().toISOString() }] }
|
|
|
|
async function roster(page: import('@playwright/test').Page) {
|
|
await signIn(page)
|
|
await answer(page, '/v1/team/rooms', ROOMS)
|
|
await answer(page, '/v1/agents', AGENTS)
|
|
await answer(page, '/v1/agents/chat/conversations', CHATS)
|
|
await answer(page, '/v1/agents/sessions', { sessions: [] })
|
|
await answer(page, '/v1/projects', [])
|
|
await answer(page, '/v1/channels/inbox', { items: [] })
|
|
}
|
|
|
|
test('a laptop: the sidebar names every place, and Home lists its people and agents', async ({ page }) => {
|
|
await roster(page)
|
|
const site = await serveExport()
|
|
try {
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.goto(`${site.url}/home`)
|
|
await rail(page, '[data-slot="list"]')
|
|
|
|
// No far-left strip beside an open sidebar: the places lead the sidebar,
|
|
// under the workspace switcher.
|
|
await expect(page.locator('[data-slot="rail"]')).toBeHidden()
|
|
await expect(page.locator('[data-slot="workspace-switcher"] select')).toHaveCount(1)
|
|
const places = page.locator('[data-slot="places-list"]')
|
|
for (const name of ['Chat', 'Dev', 'Bots', 'Contacts'])
|
|
await expect(places.getByRole('button', { name, exact: true })).toBeVisible()
|
|
|
|
const list = page.locator('[data-slot="list"]')
|
|
await expect(list.getByRole('button', { name: 'Settings', exact: true })).toBeVisible()
|
|
await expect(list.getByRole('button', { name: 'Aurora' })).toBeVisible({ timeout: 20_000 })
|
|
// The product's name leads the list, with no switch: Chat and Dev are places, each one press away.
|
|
await expect(list.locator('[data-slot="mode-name"]')).toHaveText('Hanzo AI')
|
|
await expect(page.getByRole('group', { name: 'Switch Hanzo' })).toHaveCount(0)
|
|
// People and agents are Home's own lists.
|
|
await expect(list.getByText('People', { exact: true })).toBeVisible()
|
|
await expect(list.getByRole('button', { name: 'New agent', exact: true })).toBeVisible()
|
|
|
|
// The grid, measured: the list against the window's left edge, then the pane.
|
|
const x = await page.evaluate(() =>
|
|
['list', 'pane'].map((s) => document.querySelector(`[data-slot="${s}"]`)!.getBoundingClientRect().left),
|
|
)
|
|
expect(x[0]).toBe(0)
|
|
expect(x[0]).toBeLessThan(x[1])
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1440)
|
|
|
|
await expect(page).toHaveScreenshot('shell-home-1440.png', { maxDiffPixelRatio: 0.03 })
|
|
} finally {
|
|
await site.close()
|
|
}
|
|
})
|
|
|
|
test('a phone: the rail lies along the bottom, every target is a thumb wide, and the bar opens the list', async ({ page }) => {
|
|
await roster(page)
|
|
const site = await serveExport()
|
|
try {
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await page.goto(`${site.url}/home`)
|
|
await page.locator('[data-slot="workspace"][data-hydrated="true"]').waitFor({ timeout: 15_000 })
|
|
|
|
const bar = page.locator('[data-slot="rail"]')
|
|
const box = await bar.boundingBox()
|
|
expect(box, 'the rail is drawn').not.toBeNull()
|
|
expect(box!.y + box!.height, 'the rail reaches the bottom edge').toBeGreaterThanOrEqual(840)
|
|
expect(box!.y, 'the rail is the bottom band').toBeGreaterThan(700)
|
|
|
|
// The first four places and More on the bar; the rest wait in the finder.
|
|
for (const name of ['Chat', 'Dev', 'Agents', 'Bots', 'More places'])
|
|
await expect(bar.getByRole('button', { name, exact: true })).toBeVisible()
|
|
await expect(bar.getByRole('button', { name: 'Contacts', exact: true })).toBeHidden()
|
|
|
|
for (const b of await bar.getByRole('button').all()) {
|
|
const r = await b.boundingBox()
|
|
if (r && r.width > 0) {
|
|
expect(r.height, 'a rail target is a thumb tall').toBeGreaterThanOrEqual(44)
|
|
expect(r.x + r.width, 'nothing on the rail reaches past the phone').toBeLessThanOrEqual(390)
|
|
}
|
|
}
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
|
|
|
|
// Shut until asked, then a sheet between the bar and the rail.
|
|
await expect(page.locator('[data-slot="list"]')).toHaveCount(0)
|
|
await page.getByRole('button', { name: 'Open sidebar' }).first().click()
|
|
const sheet = page.locator('[data-slot="list"]')
|
|
await expect(sheet).toBeVisible()
|
|
const s = await sheet.boundingBox()
|
|
expect(s!.width).toBeGreaterThanOrEqual(380)
|
|
expect(s!.y).toBeGreaterThanOrEqual(44)
|
|
expect(s!.y + s!.height).toBeLessThanOrEqual(box!.y + 1)
|
|
await expect(sheet.getByRole('button', { name: 'Aurora' })).toBeVisible({ timeout: 20_000 })
|
|
|
|
expect(await stacked(page), 'a place draws its glyph on its label').toEqual([])
|
|
|
|
await expect(page).toHaveScreenshot('shell-home-390.png', { maxDiffPixelRatio: 0.03 })
|
|
} finally {
|
|
await site.close()
|
|
}
|
|
})
|