Files
hanzo.ai/e2e/gates/surface.spec.ts

217 lines
9.1 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { APP, OUT, requireExport, serveExport } from './export'
import { routes, sources } from '../../lib/routes'
import { cloudPrimitiveSlugs, getPrimitive } from '../../lib/data/cloud-primitives'
/**
* The product is DRAWN on every page that claims to show it.
*
* `components/product/Surface` is DOM, so this gate can assert what a film
* could never be asked: that the thing renders,
* that it moves on its own, that a reader can take it over, that every link
* inside it opens a page the export ships, and that none of it pushes the page
* sideways on a phone.
*
* WHICH PAGES ARE MEASURED IS READ OUT OF THE SOURCE, never listed. A page asks
* for a surface by naming a catalog slug (`surface="agents"`) or by rendering
* the component directly, so a page that starts drawing one is covered by the
* next run and a spec cannot fall behind the site.
*/
/** A page names the product whose surface it draws. */
const NAMED = /\bsurface="([a-z0-9-]+)"/g
/**
* Every route that must draw the console.
*
* Three families and no list: the cloud landing, every generated primitive
* page, and every bespoke product page that names its product. A page that
* starts naming one is measured on the next run.
*/
function consoles(): string[] {
const named = new Set<string>()
for (const route of routes(APP)) {
const source = sources(route).map((file) => readFileSync(file, 'utf8')).join('\n')
for (const [, slug] of source.matchAll(NAMED)) named.add(slug)
}
return [
// cloud.hanzo.ai's own root, whose fold draws the console.
'/cloud',
...cloudPrimitiveSlugs.map((slug) => `/cloud/${slug}`),
// A page states the slug; the catalog states where that product's page is.
...[...named].map((slug) => getPrimitive(slug)?.href).filter((href): href is string => !!href),
]
}
/** The file the export answers a route with, or nothing. */
function answers(route: string): string | null {
const base = join(OUT, route.replace(/^\//, '') || 'index')
return [base + '.html', join(base, 'index.html')].find((p) => existsSync(p) && statSync(p).isFile()) ?? null
}
/** Every href inside the drawn figure — the rail, and the way out of it. */
function links(html: string): string[] {
const figure = /<figure data-slot="surface"[\s\S]*?<\/figure>/.exec(html)
if (!figure) return []
return [...figure[0].matchAll(/href="([^"]+)"/g)].map(([, href]) => href)
}
test('every page that draws a surface ships one, and its links open', () => {
requireExport()
const drawn = consoles()
expect(drawn.length, 'no page draws the console').toBeGreaterThan(0)
for (const route of drawn) {
const file = answers(route)
expect(file, `the export has no page for ${route}`).not.toBeNull()
const html = readFileSync(file as string, 'utf8')
expect(html, `${route} draws nothing`).toContain('data-slot="surface-window"')
for (const href of links(html)) {
// A leaf may live on another Hanzo property; only what this export
// claims to serve is this export's to answer for.
if (!href.startsWith('/')) continue
expect(answers(href.split('#')[0]), `${route} links to ${href}, which the export does not serve`)
.not.toBeNull()
}
}
})
/** One page per body shape — the three things that can move. */
const SUBJECTS = [
{ route: '/cloud', what: 'the console beside the fold' },
{ route: '/cli', what: 'a shell' },
{ route: '/agents', what: 'a conversation' },
] as const
const SIZES = [
{ width: 1440, height: 900 },
{ width: 768, height: 1024 },
{ width: 390, height: 844 },
] as const
/** Which step the surface is on, however it says so. */
const stepOf = (page: Page) =>
page.evaluate(() => {
const row = document.querySelector('[data-slot=surface-row][data-on] [data-slot=surface-name]')
if (row) return row.textContent ?? ''
return String(document.querySelectorAll('[data-slot=surface-line][data-next], [data-slot=surface-turn][data-next]').length)
})
/** Longer than either beat, so a surface that moves has moved. */
const BEATS = 3200
/**
* What a busy runner may spend booting the page before the first beat can tick.
*
* The interval that moves a surface is started in an effect, so it does not
* exist until React has hydrated — and no mark on these pages says when that
* happened. gui's `t_unmounted` is not one: measured on the shipped bytes, no
* element of /cloud ever wears the class, early or late. So the wait carries
* the boot instead of a marker that would only pretend to detect it.
*
* It is a budget, not a guess at a duration: the poll below returns the moment
* the surface moves, and this only decides how long a page is allowed to be
* slow before "it never moved" is the honest verdict.
*/
const BOOT = 20_000
/**
* MOTION IS ONE CLAIM PER PAGE; FITTING IS ONE PER WIDTH.
*
* A surface moves on one interval held in one component's state, and neither
* the interval nor the pointer that pauses it reads the window — so timing the
* same beat at three widths measures one thing three times and pays three boots
* for it. A boot is the expensive part of this test and the only part a loaded
* runner makes slow. What the window DOES decide is whether the figure fits, so
* that half is measured at every width, on the page already open.
*/
test('the surface renders, moves on its own, and hands over when asked', async ({ page }) => {
// Three loads, and each holds still twice for a beat to prove it was held.
test.setTimeout(300_000)
const server = await serveExport()
try {
for (const { route, what } of SUBJECTS) {
await page.setViewportSize(SIZES[0])
// Park the pointer off the surface first. A surface PAUSES under a
// pointer, and the hover this test leaves behind on the previous subject
// would still be over the next one — which reads as "never moved".
await page.mouse.move(0, 0)
await page.goto(server.url + route, { waitUntil: 'load' })
const figure = page.locator('figure[data-slot=surface]').first()
await expect(figure, `${what} draws nothing`).toBeVisible()
// GET THE SCRIPT INTO THE PAGE BEFORE TIMING WHAT THE SCRIPT DOES.
// `load` fires before the chunks are in, and `networkidle` is the half
// of the wait that can actually be observed.
await page.waitForLoadState('networkidle')
// It moves by itself. Polled, not waited for: a beat is 2.6 s, and a
// loaded runner can hold a tick past a fixed 3.2 s and read a surface
// that moves as one that never did.
const first = await stepOf(page)
await expect
.poll(() => stepOf(page), { message: `${what} never moved`, timeout: BOOT + 3 * BEATS })
.not.toBe(first)
// A pointer holds it where the reader put it.
const steps = figure.locator('[data-slot=surface-row], [data-slot=surface-line], [data-slot=surface-turn]')
const last = steps.nth((await steps.count()) - 1)
await last.hover()
const held = await stepOf(page)
await page.waitForTimeout(BEATS)
expect(await stepOf(page), `${what} moved out from under the reader`).toBe(held)
// A console's steps are BUTTONS and a click keeps one after the pointer
// has gone.
const picks = figure.locator('button[data-slot=surface-row], button[data-slot=surface-line], button[data-slot=surface-turn]')
if ((await picks.count()) > 0) {
await picks.first().click()
const kept = await stepOf(page)
await page.mouse.move(0, 0)
await page.waitForTimeout(BEATS)
expect(await stepOf(page), `${what} restarted once the pointer left`).toBe(kept)
}
// It is drawn at every width, and nothing it draws pushes the page
// sideways at any of them.
for (const size of SIZES) {
await page.setViewportSize(size)
const at = `${what} at ${size.width}`
await expect(figure, `${at} draws nothing`).toBeVisible()
const over = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
)
expect(over, `${at} scrolls sideways`).toBeLessThanOrEqual(1)
}
}
} finally {
await server.close()
}
})
test('stillness on request still says everything', async ({ browser }) => {
const server = await serveExport()
const page = await browser.newPage({ reducedMotion: 'reduce', viewport: { width: 1440, height: 900 } })
try {
await page.goto(server.url + '/cli', { waitUntil: 'load' })
const lines = page.locator('[data-slot=surface-line]')
await expect(lines.first()).toBeVisible()
const faded = await page.evaluate(() =>
[...document.querySelectorAll('[data-slot=surface-line]')].filter(
(el) => Number(getComputedStyle(el).opacity) < 1,
).length,
)
expect(faded, 'a step is still hidden from a reader who asked for no motion').toBe(0)
const first = await stepOf(page)
await page.waitForTimeout(BEATS)
expect(await stepOf(page), 'the surface moved for a reader who asked it not to').toBe(first)
} finally {
await page.close()
await server.close()
}
})