mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
172 lines
7.7 KiB
TypeScript
172 lines
7.7 KiB
TypeScript
// The app surface at 390px, in the states the export cannot reach.
|
|
//
|
|
// `e2e/gates/workspace.spec.ts` already holds /chat and /dev to this at their
|
|
// FIRST paint, off the shipped bytes. What it cannot check is every state a
|
|
// reader opens after hydration — the directory modal, the settings modal, the
|
|
// rail drawer, an open run — and those are the ones that overflow, because each
|
|
// mounts a panel with its own width that nothing on the first paint had.
|
|
//
|
|
// MEASURE ELEMENTS, NEVER `scrollWidth`. `@hanzo/design`'s base.css states
|
|
// `overflow-x: clip` on 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.
|
|
//
|
|
// BASE_URL=http://localhost:3000 pnpm exec playwright test --project=chromium e2e/mobile-app.spec.ts
|
|
|
|
import { test, expect, type Page } from '@playwright/test'
|
|
import { open, directory, drawer, field, settings, shot, styled } from './app'
|
|
|
|
test.describe.configure({ timeout: 120_000 })
|
|
|
|
const PHONE = { width: 390, height: 844 }
|
|
|
|
test.use({ viewport: PHONE })
|
|
|
|
/** The widest element reaching past the viewport, or null. */
|
|
async function overflow(page: Page) {
|
|
return page.evaluate(() => {
|
|
const width = window.innerWidth
|
|
let over: { tag: string; right: number; width: number } | null = null
|
|
for (const el of Array.from(document.body.querySelectorAll<HTMLElement>('*'))) {
|
|
const style = getComputedStyle(el)
|
|
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue
|
|
// An element the page itself scrolls horizontally owns its own overflow —
|
|
// a wide code block in its own scroller is correct, not a defect.
|
|
if (style.overflowX === 'auto' || style.overflowX === 'scroll') 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.dataset.slot ? `[data-slot=${el.dataset.slot}]` : ''}`,
|
|
right: Math.round(rect.right),
|
|
width: Math.round(rect.width),
|
|
}
|
|
}
|
|
}
|
|
return { width, over }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Assert the room SETTLES with nothing reaching past the phone.
|
|
*
|
|
* Polled rather than sampled once, because a single reading catches states no
|
|
* reader is left in. Measured on `/chat` at 390: the composer's send button is
|
|
* 42px wide and reaches 439 — 49px off the right edge — from first paint until
|
|
* about two seconds in, and then reflows to fit. That is a real if brief
|
|
* defect, and it is not the invariant `overflow-x: clip` exists to protect;
|
|
* asserting on it makes the gate fire on hydration timing rather than layout.
|
|
*
|
|
* So what is held is the resting state, and the last reading is what gets
|
|
* named when it never arrives.
|
|
*
|
|
* The picture is taken BEFORE the assertion, always. A screenshot only on the
|
|
* happy path is the wrong way round — a failing state is the one a reader needs
|
|
* to look at, and an assertion that throws first never gets to take it.
|
|
*/
|
|
async function fits(page: Page, state: string) {
|
|
let worst = await overflow(page)
|
|
const until = Date.now() + 15_000
|
|
while (worst.over && Date.now() < until) {
|
|
await page.waitForTimeout(400)
|
|
worst = await overflow(page)
|
|
}
|
|
await shot(page, `mobile-${state.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`)
|
|
expect(
|
|
worst.over,
|
|
`${state} at ${worst.width}px still overflows after 15s: ` +
|
|
`${worst.over?.tag} is ${worst.over?.width}px and reaches to ${worst.over?.right}`,
|
|
).toBeNull()
|
|
}
|
|
|
|
test.describe('the app surface on a phone', () => {
|
|
test('the room fits, at rest and with the rail open', async ({ page }) => {
|
|
for (const route of ['/chat']) {
|
|
await open(page, route)
|
|
await styled(page)
|
|
await fits(page, `${route} at rest`)
|
|
|
|
// The rail is one element moved by CSS: below $sm it is display:none
|
|
// until the drawer opens, so its width is only ever measurable here.
|
|
const opener = page.getByRole('button', { name: 'Open sidebar' })
|
|
if (await opener.count()) {
|
|
await opener.first().click()
|
|
await expect(page.locator('[data-slot="sidebar"]').first()).toBeVisible()
|
|
await fits(page, `${route} with the rail open`)
|
|
await page.keyboard.press('Escape')
|
|
}
|
|
}
|
|
})
|
|
|
|
test('the composer stays on screen and takes a draft at 390', async ({ page }) => {
|
|
await open(page, '/chat')
|
|
const draft = field(page)
|
|
await expect(draft).toBeVisible({ timeout: 30_000 })
|
|
|
|
// A composer that is on the page but off the side of it is not usable, and
|
|
// it is exactly what a fixed-width panel inside a flex row produces.
|
|
const box = await draft.boundingBox()
|
|
expect(box, 'the composer must have a box').not.toBeNull()
|
|
expect(box!.x, 'the composer must not start off the left edge').toBeGreaterThanOrEqual(-1)
|
|
expect(box!.x + box!.width, 'the composer must not reach past the right edge').toBeLessThanOrEqual(PHONE.width + 1)
|
|
|
|
await draft.click()
|
|
await draft.pressSequentially('a question typed on a phone', { delay: 8 })
|
|
await fits(page, '/chat with a draft')
|
|
})
|
|
|
|
test('a phone can still reach the rail, the rooms and both app actions', async ({ page }) => {
|
|
await open(page, '/chat')
|
|
|
|
// Below $sm the rail is behind the drawer, so ALL of it is legitimately
|
|
// hidden until the toggle is pressed — and every rung must be there when
|
|
// it is. A room a phone cannot navigate to is a room a phone does not have.
|
|
await expect(
|
|
page.getByRole('button', { name: 'Open sidebar' }),
|
|
'a phone needs a way to open the rail',
|
|
).toBeVisible()
|
|
|
|
// Through the helper rather than by pressing the toggle directly: it reads
|
|
// the rail's state first, so it is idempotent. Pressing blind toggles a
|
|
// drawer that some other step already opened, and the assertion below then
|
|
// reads `hidden` on a rail that had been up a moment earlier.
|
|
await drawer(page)
|
|
await expect(page.locator('[data-slot="sidebar"]').first()).toBeVisible({ timeout: 15_000 })
|
|
await shot(page, 'mobile-drawer')
|
|
|
|
// Settings is asserted and Customize is not, because Customize is no
|
|
// longer a rung: its four rows moved inside Settings, so Settings is now
|
|
// the only way in to both. Naming the old control here would report a
|
|
// phone-only defect on a surface that simply reorganised.
|
|
await expect(
|
|
page.getByRole('button', { name: 'Settings', exact: true }),
|
|
'Settings must be reachable on a phone — it is the only way in to the whole customize taxonomy',
|
|
).toHaveCount(1)
|
|
|
|
// And the rooms have to be reachable too, or a phone has no rail at all.
|
|
const rooms = page.locator('button[aria-current], [role="button"][aria-current]')
|
|
expect(await rooms.count(), 'a phone must be able to reach the rooms').toBeGreaterThan(0)
|
|
})
|
|
|
|
test('the modals fit the phone they open on', async ({ page }) => {
|
|
await open(page, '/chat')
|
|
|
|
const panel = await directory(page)
|
|
await fits(page, 'the directory on a phone')
|
|
|
|
// Every tab, because each renders a different body and only one of them
|
|
// was ever laid out against a narrow column.
|
|
for (const tab of ['Channels', 'Plugins', 'Skills'] as const) {
|
|
await panel.getByRole('button', { name: tab, exact: true }).click()
|
|
await fits(page, `the directory's ${tab} tab on a phone`)
|
|
}
|
|
|
|
await page.getByRole('button', { name: 'Close directory' }).click()
|
|
await settings(page)
|
|
await fits(page, 'settings on a phone')
|
|
})
|
|
})
|