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

124 lines
5.4 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
import { serveExport } from './export'
import { signIn, answer } from '../session'
/**
* ⌘K IN THE APP FINDS WHAT IS NOT IN THE ROOM.
*
* The chord used to focus the column's filter box, so the control that offered
* to "Search Hanzo" narrowed the one list already on screen — a project was
* unreachable from /chat and a conversation unreachable from /dev, by the
* shortcut whose whole promise is that you do not have to know where a thing
* lives.
*
* So the assertion is deliberately CROSS-ROOM: stand in /inbox, whose column
* lists no projects, and find a project. A test that searched for what the
* column already lists would pass against the old filter box too.
*/
/**
* The palette's field. The palette is @hanzo/app' one search (Palette.tsx,
* rooms 0.1.9x): the workspace frame (Shell) mounts it on ⌘K in place of the
* old `OrgCommandPalette`, whose field read "Search commands".
*/
const FIELD = 'input[placeholder="Search or jump to…"]'
const ROW = '[role="option"]'
/**
* Open the palette by the chord.
*
* Retried, because `load` fires when the bytes are down and the listener is
* attached by an effect some milliseconds later — one press races hydration and
* goes red for a reason that is not the palette. Pressing only while the field
* is absent means a retry cannot toggle shut a palette that just opened.
*/
async function open(page: Page): Promise<void> {
await expect(async () => {
if ((await page.locator(FIELD).count()) === 0) await page.keyboard.press('Meta+k')
await expect(page.locator(FIELD)).toBeFocused({ timeout: 1000 })
}).toPass({ timeout: 20000 })
}
/** The titles the palette offers for `query`. */
async function results(page: Page, query: string): Promise<string[]> {
await page.locator(FIELD).fill(query)
await expect(page.locator(ROW).first()).toBeVisible({ timeout: 5000 })
return page.locator(ROW).allInnerTexts()
}
test.describe('the workspace palette', () => {
test.beforeEach(async ({ page }) => {
await signIn(page)
// A project row as cloud answers it: the palette keys a project on its slug
// and names it by its name (@hanzo/app Palette `projects`).
await answer(page, '/v1/projects', [
{ id: 'p-borealis', slug: 'borealis', name: 'Borealis' },
{ id: 'p-other', slug: 'cartography', name: 'Cartography' },
])
await answer(page, '/v1/agents/chat/conversations', {
conversations: [{ id: 't-aurora', title: 'Aurora', updatedAt: new Date().toISOString() }],
})
})
test('finds a project from the inbox, and a room by name', async ({ page }) => {
const site = await serveExport()
try {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${site.url}/inbox`)
// THE COMPOSER IS VISIBLE BEFORE IT CAN SEND. The organization gate added a
// render pass in front of every room, so a keystroke landing between paint and
// hydration is dropped — which a person typing straight after load would also
// lose. `data-hydrated` is the shell's own readiness, and the same signal
// `rail()` waits on.
await page.locator('[data-slot="workspace"][data-hydrated="true"]').first().waitFor({ timeout: 20_000 })
await open(page)
// THE CROSS-ROOM CLAIM. /inbox's column has no projects in it at all.
const projects = (await results(page, 'borealis')).join(' | ')
expect(projects, 'a project is unreachable from the inbox').toContain('Borealis')
// A destination answers to its own name.
const rooms = (await results(page, 'drive')).join(' | ')
expect(rooms, 'a room is unreachable by name').toContain('Drive')
// THE ROOM WITH NO OTHER DOOR. Home is off the rail by design and no
// control anywhere else points at it, so the palette is how it is
// reached at all — and it answers to the word a reader knows it by,
// which is not the brand its browser tab carries.
const home = (await results(page, 'home')).join(' | ')
expect(home, 'Home has no door at all').toContain('Home')
// ONE ROW PER DESTINATION. The estate's app registry sends Chat to
// hanzo.chat, which is the product this workspace subsumes — so without
// dropping it the palette offered two rows called Chat, one of which left
// for the thing the reader was already standing in.
const chat = (await results(page, 'chat')).filter((row) => row.startsWith('Chat'))
expect(chat, 'two rows called Chat').toHaveLength(1)
} finally {
await site.close()
}
})
test('the column keeps a filter, and only the bar claims search', async ({ page }) => {
const site = await serveExport()
try {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${site.url}/inbox`)
// Two controls both saying Search did two different things.
// Found by ATTRIBUTE rather than by role: the drawer starts away in this
// harness, and a closed column is out of the accessibility tree — so the
// role query measures whether the sidebar is open, which is a different
// test that already exists in columns.spec.
const box = page.locator('input[aria-label="Filter sidebar"]')
await expect(box).toHaveAttribute('placeholder', 'Filter', { timeout: 20000 })
// And the chord no longer lands in it.
await open(page)
await expect(box).not.toBeFocused()
} finally {
await site.close()
}
})
})