Files
hanzo.ai/e2e/gates/workspace.spec.ts
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

300 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test'
import { rail } from './rail'
import { serveExport, read } from './export'
import { signIn } from '../session'
import { ROOMS } from '../../lib/publish'
import { CHAT } from '@hanzo/app/lib/host'
const SHARED = '/chat/shared'
/**
* The app surface: the rooms, in the one shell every room wears, and /chat and
* /dev, the app's own pages.
*
* These routes are the product rather than a page about it, so what they owe
* the export is the opposite of what a marketing page owes it — none of the
* room may be in the shipped bytes, and all of it must be there for a reader
* with a session, without the page growing sideways.
*
* THE INSTRUMENT IS CHECKED BEFORE ANY NUMBER IS BELIEVED. gui compiles a style
* prop to an atomic class in an external sheet; a document that loaded no sheet
* answers every layout question with the base rule and passes a geometry test
* by measuring nothing. So each viewport confirms the sidebar computes as a
* flex box first, and only then measures.
*/
const LAPTOP = { width: 1440, height: 900 }
const PHONE = { width: 390, height: 844 }
/** The rail names itself, so nothing here walks N parents up to find it. */
const RAIL = '[data-slot="sidebar"]'
const COMPOSER = '[data-slot="composer"]'
/**
* Wait for the page to be MOUNTED, which is not the same as waiting for a rail.
*
* Hydration is what turns the shipped frame into the app, and every interactive
* assertion below is false until it has happened. `load` fires well before it.
*
* This waited on the rail, and that made it a room detector rather than a
* hydration one — so it timed out on `/dev`, which signed out is the product
* page and carries no rail by design. The tests that need a rail assert one
* themselves; that is where the requirement belongs.
*
* `t_unmounted` is gui's own marker, present on both exports and cleared when it
* mounts, so it says the thing this function is named for. The swallow stays:
* it is a wait, not an assertion, and every caller measures something real
* afterwards — a helper that fails the test before the measurement replaces a
* useful message with a timeout on a helper.
*/
async function hydrated(page: Page) {
await page
.waitForFunction(() => !document.documentElement.className.includes('t_unmounted'), null, {
timeout: 15000,
})
.catch(() => {})
// AND WAIT FOR THE LAYOUT TO STOP MOVING, which mounting does not imply.
// /dev's hero animates in — framer-motion drives transform and opacity — so a
// box measured between mount and settle reads a position it never rests at.
// That is not hypothetical: measured mid-flight, /dev's 800px glow reported a
// right edge of 995 on a 390px phone and the width gate called it overflow.
// At rest nothing on either room exceeds the viewport at all.
//
// Settled means two consecutive frames agree on the widest right edge, which
// asks the page rather than guessing at a duration.
await page
.waitForFunction(
() => {
const widest = () => {
let right = 0
for (const el of Array.from(document.body.querySelectorAll<HTMLElement>('*'))) {
const r = el.getBoundingClientRect()
if (r.width && r.height && r.right > right) right = r.right
}
return Math.round(right)
}
const w = window as unknown as { __settled?: number }
const now = widest()
const was = w.__settled
w.__settled = now
return was === now
},
null,
{ timeout: 15000, polling: 250 },
)
.catch(() => {})
}
test.describe('the app surface ships a real room', () => {
test('no room is on disk: the export ships the veil and nothing of the product', () => {
// Read off the bytes, not the browser: this is what a crawler is served and
// what a browser paints before any answer exists.
//
// THE EXPORT USED TO BE THE ROOM. This asserted the opposite of what it
// asserts now — the rail and the composer in chat.html, one heading per room
// — because the organization gate handed the room over on its first render.
// That put the whole paid interface in the file: the browser painted it,
// billing answered, and the ask arrived over a product the reader had
// already been shown. Every room's first render is the veil now, and
// chat.html went from 83,608 bytes with a composer and a textarea to 35,939
// with neither. A room creeping back into the export is that flash coming
// back, so its absence is the gate.
//
// EVERY ROOM, from the list the build already derives — `lib/publish`'s
// ROOMS, written by scripts/sync-rooms.mjs off the same app tree that
// decides whether a room exists at all. A room added next week is measured
// on the next run rather than when somebody remembers to add it here.
for (const room of ROOMS) {
const html = read(`${room.replace(/^\//, '')}.html`)
// The veil, so this is a page that gated its room rather than a page that
// failed to build. An absence gate over an empty file passes on nothing.
// It is a gui View, so its sheet over the viewport is its atomic classes.
// /chat is the app's page (app/(app)/chat/_door.tsx) and gates nothing:
// its own title says it built. /chat/shared is read by people outside the
// org, so it holds no room to veil; its title says it built. (/dev is not
// a room: signed out it is Dev's page, held by dev.spec.ts.)
if (room === CHAT) expect(html, `${room} ships its page`).toContain('<title>Hanzo Chat')
else if (room === SHARED) expect(html, `${room} ships its page`).toContain('<title>A chat shared with you')
else
expect(html, `${room} must ship the veil over its room`).toMatch(
/aria-hidden="true"[^>]*_pos-fixed _t-0px _r-0px _b-0px _l-0px/,
)
for (const [what, mark] of [
['the rail', 'data-slot="sidebar"'],
['a composer', 'data-slot="composer"'],
['a field', '<textarea'],
['its room', '<main'],
] as const) {
expect(html, `${room} ships ${what}`).not.toContain(mark)
}
}
})
test('the room renders at 1440 and the composer takes a draft', async ({ page }) => {
await signIn(page)
const server = await serveExport()
try {
await page.setViewportSize(LAPTOP)
await page.goto(`${server.url}/home`, { waitUntil: 'load' })
await hydrated(page)
// THE RAIL IS A SHEET, and it starts away: the app mark is its handle, and
await rail(page, RAIL)
await page.waitForTimeout(900)
// The instrument: a rail that is not a flex box means no stylesheet, and
// every measurement below would be of an unstyled document.
const display = await page.locator(RAIL).first().evaluate((el) => getComputedStyle(el).display)
expect(display, 'the rail must compute as flex, or no gui stylesheet arrived').toBe('flex')
await expect(page.locator(RAIL).first()).toBeVisible()
const field = page.locator(`${COMPOSER} textarea`).first()
await expect(field).toBeVisible()
await field.fill('a question the composer must accept')
await expect(field).toHaveValue('a question the composer must accept')
} finally {
await server.close()
}
})
test('Dev is the door to the app: the switch leaves the rooms for `/`, the app with its own column', async ({ page }) => {
await signIn(page)
// The app's reads, answered: no runs, no projects. What is asked here is
// where the app sits, not what the platform holds.
await page.route('**/v1/agent/sessions**', (route) =>
route.request().url().includes('/stream')
? route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
: route.fulfill({ json: { sessions: [] } }),
)
await page.route('**/v1/projects', (route) => route.fulfill({ json: [] }))
const server = await serveExport()
try {
await page.setViewportSize(LAPTOP)
await page.goto(`${server.url}/home`, { waitUntil: 'load' })
await hydrated(page)
await rail(page, RAIL)
await page.locator('[data-slot="places-list"]').getByRole('button', { name: 'Dev', exact: true }).click()
// Dev in the app, at `/dev`.
await page.waitForURL((u) => u.pathname === '/dev' && !u.search, { timeout: 15000 })
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 15000 })
// ONE sidebar, and it is the app's, beside its rail standing on Dev: the
// name, New, its places. The rooms' sidebar is not on the page.
await expect(page.locator('[data-slot="app-rail"]')).toHaveCount(1)
await expect(page.locator(RAIL)).toHaveCount(0)
const side = page.locator('[data-slot="app-rail"]')
await expect(page.getByRole('navigation', { name: 'Products' }).getByRole('button', { name: 'Dev', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(side.getByText('Hanzo Dev', { exact: true })).toBeVisible()
// Dev's own places in its sidebar (@hanzo/build `own`); what a run made is
// the Library, a place on the rail (at /dev/artifacts), not a row here.
for (const row of ['New run', 'Projects', 'Issues', 'Templates']) await expect(side.getByText(row, { exact: true })).toBeVisible()
await expect(side.getByText('Library', { exact: true })).toHaveCount(0)
// A place on the rail moves the app and the address with it, and takes the sidebar.
await page.getByRole('navigation', { name: 'Products' }).getByRole('button', { name: 'Library', exact: true }).click()
await page.waitForURL((u) => u.pathname === '/dev/artifacts', { timeout: 15000 })
await expect(side.getByRole('button', { name: 'Back' })).toBeVisible()
} finally {
await server.close()
}
})
test('home is a room of its own', async ({ page }) => {
await signIn(page)
const server = await serveExport()
try {
// /home FORWARDED to /chat and does not any more — it is the workspace's
// own landing again. What was wrong was never the address: it was a house
// in the rail beside six labelled destinations, a seventh word for the
// place the corner mark already goes to. The rail carries no Home tile,
// and this asserts the forward has not quietly come back with it.
await page.goto(`${server.url}/home`, { waitUntil: 'load' })
await expect(page).toHaveURL(/\/home$/)
} finally {
await server.close()
}
})
/**
* MEASURE ELEMENTS, NEVER `scrollWidth`.
*
* `@hanzo/design`'s base.css states `overflow-x: clip` on both `html` and `body`, which
* is the invariant that keeps the page from scrolling sideways — and it makes
* `documentElement.scrollWidth > clientWidth` impossible to observe. A gate
* written that way cannot fail: injecting a 2000px element into the shipped
* `chat.html` left it green. The clip hides the overflow from the scrollport,
* not from the box that overflows, so the box is what gets read.
*/
test('nothing in either room reaches past a phone', async ({ page }) => {
// Signed out there is no room to measure, and a width gate over a veil is a
// gate that cannot fail — the same shape as the `scrollWidth` reading above.
await signIn(page)
const server = await serveExport()
try {
await page.setViewportSize(PHONE)
for (const route of ['/chat', '/home']) {
await page.goto(`${server.url}${route}`, { waitUntil: 'load' })
await hydrated(page)
const worst = await page.evaluate(() => {
const width = window.innerWidth
// An element the page itself scrolls horizontally owns its own
// overflow — a wide code block in its own scroller is correct. That
// was always the rule here, but it was only ever asked of the scroller
// ITSELF, and a scroller is never the thing that overflows: its
// CONTENTS are. So the crew under the composer, eleven 68px tiles on
// one line inside `overflow-x: auto`, reported a tile reaching 997 on
// a 390px phone while the strip scrolled correctly — 1012 of content
// in 389 of room, 623 of it reachable by dragging.
//
// `auto`/`scroll` only, never `hidden` or `clip`: content a reader can
// scroll to is reachable, and content clipped away is exactly the
// defect this gate exists for.
const scrolled = (el: HTMLElement): boolean => {
for (let n: HTMLElement | null = el; n && n !== document.body; n = n.parentElement) {
const ox = getComputedStyle(n).overflowX
if (ox === 'auto' || ox === 'scroll') return true
}
return false
}
let over: { tag: string; right: number } | null = null
for (const el of Array.from(document.body.querySelectorAll<HTMLElement>('*'))) {
const style = getComputedStyle(el)
if (style.display === 'none' || style.visibility === 'hidden') continue
if (scrolled(el)) continue
// DECORATION IS NOT LAYOUT. A box that takes no pointer is not
// something a reader can touch, read or scroll to — it is paint.
// /dev's hero glow is an 800px circle at 15% opacity with
// `pointer-events: none`, so its rect honestly reads 995 on a 390px
// phone while nothing about the page is wrong.
//
// Deliberately NOT "is it clipped": the room itself sits inside an
// overflow-hidden box, so excusing clipped boxes excused everything
// in the room. Measured — a 2000px probe rendered into the composer
// passed a gate written that way, which is the shape of a gate that
// cannot fail. Content that overflows is a defect whether or not it
// is clipped; being clipped is what makes it unreachable.
if (style.pointerEvents === 'none') continue
const rect = el.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) continue
if (rect.right > width + 1 && (!over || rect.right > over.right)) {
over = { tag: `${el.tagName.toLowerCase()}.${el.className}`.slice(0, 90), right: rect.right }
}
}
return { width, over }
})
expect(
worst.over,
`${route} at ${worst.width}px has an element reaching to ${worst.over?.right}: ${worst.over?.tag}`,
).toBeNull()
}
} finally {
await server.close()
}
})
})