Files
zeekayandHanzo Dev 146050c5b6 The app is one shell: a rail of apps, a sidebar on the floor and the content inset, on @hanzo/rooms 0.1.58
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]>
2026-10-16 15:05:24 -07:00

171 lines
7.2 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
import { rail } from './rail'
import { serveExport } from './export'
import { signIn, answer } from '../session'
/**
* HOME IS THE ORG'S ROOM: read, answer, mention an agent, open a new one.
*
* Every read a room makes is answered here with the platform's own shapes —
* `/v1/team/rooms`, `/v1/iam/users`, `/v1/team/bots`, the transcript — so what
* this measures is the CLIENT: that an author id becomes a name through the
* roster, that the transcript is polled, that what you say is on screen before
* the platform answers and the platform's row replaces it, that the roster
* stands beside the room, and that a new room is posted with the reader as its
* member and opened at once.
*/
const ME = '2d4d67ab-30f1-474e-b81f-f60461852259'
const VI = '9e5d70db-ebd8-5f94-a721-d2aa074123a3'
const ROOMS = {
rooms: [
{ id: 'r1', space: 'acme', name: 'general', direct: false, private: false, archived: false, members: [] },
],
}
const USERS = { users: [{ id: ME, name: 'z', displayName: 'Z', avatar: '' }], total: 1 }
const BOTS = { bots: [{ id: 'agent_1', name: 'vi', userId: VI, personRef: `person-${VI}`, active: true }] }
const at = Date.now()
/**
* The room's transcript, as the platform serves it: polled by the client, a
* send answered with the row it stored, and the agent's answer in the next
* read. The send is answered slowly on purpose — what is on screen before it
* lands is the client's own doing.
*/
async function transcript(page: Page) {
let reads = 0
let posted: string | null = null
let sent = ''
await page.route(
(url) => url.pathname.endsWith('/v1/team/rooms/r1/messages'),
async (route) => {
if (route.request().method() === 'POST') {
posted = route.request().postData()
sent = (JSON.parse(posted ?? '{}') as { text?: string }).text ?? ''
await new Promise((r) => setTimeout(r, 800))
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'm3', room: 'r1', author: ME, text: sent, createdOn: at + 1 }),
})
}
reads += 1
const messages = [{ id: 'm1', room: 'r1', author: ME, text: 'ship it', createdOn: at }]
if (posted) {
messages.push({ id: 'm3', room: 'r1', author: ME, text: sent, createdOn: at + 1 })
messages.push({ id: 'm2', room: 'r1', author: VI, text: 'planning it', createdOn: at + 2 })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ messages }) })
},
)
return { reads: () => reads, posted: () => posted }
}
/** The org's rooms, and the room a POST opens. */
async function rooms(page: Page) {
let made: string | null = null
await page.route(
(url) => url.pathname === '/v1/team/rooms',
(route) => {
if (route.request().method() === 'POST') {
made = route.request().postData()
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 'r2',
space: 'acme',
name: 'bugfix',
direct: false,
private: false,
archived: false,
members: [ME],
}),
})
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ROOMS) })
},
)
return () => made
}
test('a room is read, answered, polled, and opened from home', async ({ page }) => {
// Two polls of two seconds and a slow send are waited for on purpose.
test.setTimeout(120_000)
await signIn(page, { sub: ME, name: 'z', org: 'hanzo' })
await answer(page, '/v1/iam/users', USERS)
await answer(page, '/v1/team/bots', BOTS)
const made = await rooms(page)
const said = await transcript(page)
const site = await serveExport()
try {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${site.url}/home`)
await rail(page, '[data-slot="sidebar"]')
await page.getByRole('button', { name: 'general', exact: true }).first().click()
const main = page.locator('main')
// THE JOIN. The author is an account; the roster names it.
await expect(main).toContainText('ship it', { timeout: 20_000 })
await expect(main.getByText('Z', { exact: true }).first()).toBeVisible()
// The room rides the address.
await expect
.poll(() => new URL(page.url()).searchParams.get('room'), { timeout: 5_000 })
.toBe('team:acme/r1')
// THE POLL. Two seconds apart while the room is open.
await page.waitForTimeout(5_000)
expect(said.reads(), 'the room was not polled').toBeGreaterThanOrEqual(3)
// WHAT YOU SAY IS ON SCREEN BEFORE THE PLATFORM ANSWERS, and the body the
// platform gets carries the space as well as the words.
const field = main.locator('textarea').first()
await field.fill('hey @vi')
await field.press('Enter')
await expect(main.getByText('hey @vi', { exact: true })).toBeVisible({ timeout: 300 })
await expect.poll(() => said.posted(), { timeout: 10_000 }).toContain('"space":"acme"')
expect(said.posted()).toContain('"text":"hey @vi"')
// THE AGENT'S ANSWER lands on the next read, under its own name.
await expect(main).toContainText('planning it', { timeout: 10_000 })
await expect(main.getByText('vi', { exact: true }).first()).toBeVisible()
// THE ROSTER, beside the room: a person and an agent, told apart.
//
// OPENED BY ADDRESS, which is the only door the shell leaves. Its one side
// control asks for `tasks`; nothing asks for `roster`, though Talk still
// draws the pane on it. `?aside=` carries the pane the way `?room=` carries
// the room, so a link still opens it — and what is IN it is this gate.
await page.goto(`${site.url}/home?room=team:acme/r1&aside=roster`)
// THE FRAME OWNS THE COLUMN AND THE ROOM OWNS WHAT IS IN IT, so the column
// is there the moment the address is read and stays empty until the room
// arrives to fill it. Reading it before then is reading the frame, which is
// a column with nothing in it however long you look at it.
await expect(main).toContainText('ship it', { timeout: 20_000 })
const aside = page.locator('aside').first()
await expect(aside).toContainText('Z', { timeout: 10_000 })
await expect(aside).toContainText('vi')
await expect(aside).toContainText('agent')
// A NEW ROOM, from the Rooms card: posted with the reader as its member,
// in the org's space, and opened at once.
await page.goto(`${site.url}/home`)
const card = main.locator('[data-card="rooms"]')
await card.getByRole('button', { name: 'New room' }).click({ timeout: 20_000 })
await card.getByLabel('Channel name').fill('bugfix')
await card.getByRole('button', { name: 'Open it' }).click()
await expect.poll(() => made(), { timeout: 10_000 }).toContain('"name":"bugfix"')
expect(made()).toContain('"space":"acme"')
expect(made()).toContain(`"members":["${ME}"]`)
await expect(main).toContainText('#bugfix', { timeout: 10_000 })
// A LINK OPENS THE SAME ROOM: the address is read on arrival.
await page.goto(`${site.url}/home?room=team:acme/r1`)
await expect(main).toContainText('ship it', { timeout: 20_000 })
} finally {
await site.close()
}
})