Files
hanzo.ai/e2e/mobile-app.spec.ts

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')
})
})