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.
300 lines
14 KiB
TypeScript
300 lines
14 KiB
TypeScript
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()
|
||
}
|
||
})
|
||
})
|