Files
hanzo.ai/e2e/composer.spec.ts

89 lines
4.0 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { credential, signedIn } from './credential'
/**
* A COMPOSER'S CONTROLS STAY WHERE A HAND CAN REACH THEM.
*
* The defect this guards is not a style: the model picker's trigger is
* `width: 100%`, and in a flex row that resolves against whatever it is given —
* unbounded, that is the whole row, and the mic and send are laid out past the
* capsule and off the window. Measured at 1280 the picker was 750 wide in a 750
* row and send began at 1283. Nothing about that reads as broken in the source.
*
* Both composers are checked, because this site has two and they answer to
* different rules: /chat draws @hanzo/ui's `Composer` (a field over a footer
* row), and the landing and /dev draw @hanzo/composer's gui components
* (`Composer`, `Control`, `Field`). A guard on one says nothing about
* the other.
*
* Four widths, because the failure was invisible at the widest and total at the
* narrowest.
*/
const SIZES = [
{ w: 1440, h: 900 },
{ w: 1280, h: 720 },
{ w: 834, h: 1112 },
{ w: 390, h: 844 },
]
/** Everything in a composer that a reader presses or types into. */
const CONTROLS = 'input, textarea, [data-slot="prism-control"], [role="combobox"], [aria-label="Send"]'
test.describe('composer', () => {
test('the workspace composer keeps its controls inside the capsule', async ({ page }) => {
const TOKEN = credential()
test.skip(!TOKEN, 'no local credential: the room needs somebody signed in')
await page.addInitScript(signedIn(TOKEN), TOKEN)
for (const s of SIZES) {
await page.setViewportSize({ width: s.w, height: s.h })
await page.goto('/chat')
await page.waitForLoadState('networkidle')
await page.evaluate(() => document.fonts.ready)
const look = () => page.evaluate((sel) => {
const field = document.querySelector('textarea')
if (!field) return ['no composer']
// The capsule is the first ancestor as wide as the field's own column.
// Relative, because on a phone nothing is 700 wide and an absolute floor
// walks off the top of the document.
let cap: HTMLElement = field.parentElement!
const want = Math.min(700, innerWidth * 0.6)
while (cap.parentElement && cap.getBoundingClientRect().width < want) cap = cap.parentElement
const edge = cap.getBoundingClientRect().right
return [...cap.querySelectorAll(sel)]
.map((e) => ({ name: e.getAttribute('aria-label') || e.tagName, r: e.getBoundingClientRect() }))
.filter((c) => c.r.width > 0 && (c.r.right > edge + 1 || c.r.right > innerWidth + 1 || c.r.bottom > innerHeight + 1))
.map((c) => c.name)
}, CONTROLS)
await expect.poll(look, { timeout: 10_000, message: `at ${s.w}x${s.h}` }).toEqual([])
}
})
for (const route of ['/']) {
test(`the composer on ${route} keeps its controls inside the form`, async ({ page }) => {
test.slow()
for (const s of SIZES) {
await page.setViewportSize({ width: s.w, height: s.h })
await page.goto(route)
await page.waitForLoadState('networkidle')
await page.evaluate(() => document.fonts.ready)
const look = () => page.evaluate((sel) => {
const form = document.querySelector('[data-slot="prism"]')
if (!form) return ['no composer']
// The fold takes the whole first screen now, so the composer starts
// below it: bring it on screen, because a control under the fold is
// not one outside its form.
form.scrollIntoView({ block: 'center' })
const edge = form.getBoundingClientRect().right
return [...form.querySelectorAll(sel)]
.map((e) => ({ name: e.getAttribute('aria-label') || e.tagName, r: e.getBoundingClientRect() }))
.filter((c) => c.r.width > 0 && (c.r.right > edge + 1 || c.r.right > innerWidth + 1 || c.r.bottom > innerHeight + 1))
.map((c) => c.name)
}, CONTROLS)
await expect.poll(look, { timeout: 10_000, message: `at ${s.w}x${s.h}` }).toEqual([])
}
})
}
})