Files
hanzo.ai/e2e/gates/chrome.spec.ts
Cursor Agentandz 5561f61683 site: glass at rest, and counts from this build's snapshots
The marketing bar is glass before the page scrolls. The chrome, palette, and homepage gates follow the new nav. The model and repository snapshots this build read are 561 and 825.

Co-authored-by: z <[email protected]>
2026-10-26 22:10:56 +00:00

230 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { test, expect, type Page } from '@playwright/test'
import { serveExport } from './export'
import { MOVED } from '../../lib/publish'
import { FOOT, FOOT_LINE, GET_KEY, LEGAL, LOGIN, MENUS, away } from '../../components/home/nav-data'
const OUT = join(process.cwd(), 'out')
/**
* The chrome is ONE tree (components/home/nav-data.ts) for the bar and the
* phone sheet. The footer is the short set, FOOT, and the line under it.
*
* Measured on the export, because the menus' rows exist only once a menu has
* opened and the sheet only once it is tapped. Every row the tree names must be
* a page the export carries and not a forward, or a host we meant to send a
* reader to.
*/
const TREE = MENUS
/** The entries that open a menu; one with no columns is a link to its page. */
const OPENS = TREE.filter((menu) => menu.groups.length > 0)
const LINKS = MENUS.filter((menu) => menu.groups.length === 0)
const ROWS = TREE.flatMap((menu) => menu.groups.flatMap((group) => group.links))
const HOSTS = ['docs.hanzo.ai', 'platform.hanzo.ai', 'hanzo.app']
/** A menu's rows answer: a page the export carries, never a forward, or a host we name. */
function unanswered(hrefs: string[]): string[] {
return hrefs.filter((href) => {
if (href.startsWith('#')) return false // a control on this page, not a page
if (away(href)) return !HOSTS.includes(new URL(href).host)
if (href in MOVED) return true
// A page's own anchor answers when the page does.
const rel = href.split(/[?#]/)[0].replace(/^\//, '') || 'index'
return !existsSync(join(OUT, `${rel}.html`)) && !existsSync(join(OUT, rel, 'index.html'))
})
}
/** Wait for the bar to answer its own pointer: a hover before hydration opens nothing. */
async function open(page: Page, trigger: ReturnType<Page['locator']>, id: string) {
await expect(async () => {
await trigger.hover()
await expect(page.locator(`#menu-${id}`)).toBeVisible({ timeout: 1000 })
}, `the ${id} menu opens`).toPass({ timeout: 15000 })
}
test.describe('the chrome', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('the tree names only pages that answer', () => {
expect(unanswered([...ROWS, ...LINKS, LOGIN, GET_KEY, ...LEGAL].map((l) => l.href))).toEqual([])
})
test('the bar reads Hanzo AI, six doors, search, then Log in and Get API Key', async ({ page }) => {
expect(MENUS.map((m) => m.label)).toEqual(['Models', 'Dev', 'Platform', 'Pricing', 'Docs', 'Enterprise'])
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`, { waitUntil: 'load' })
const bar = page.getByRole('banner')
const parts = [
bar.getByRole('link', { name: 'Hanzo AI home' }),
...MENUS.map((m) => bar.getByRole(m.groups.length ? 'button' : 'link', { name: m.label, exact: true })),
bar.getByRole('button', { name: 'Search Hanzo or ask AI' }),
bar.getByRole('link', { name: 'Log in' }),
bar.getByRole('link', { name: 'Get API Key' }),
]
const xs: number[] = []
for (const part of parts) {
await expect(part).toBeVisible()
xs.push((await part.boundingBox())!.x)
}
expect(xs, 'left to right in that order').toEqual([...xs].sort((a, b) => a - b))
// Every door wears its mark before its label.
for (const m of MENUS) {
await expect(bar.getByRole(m.groups.length ? 'button' : 'link', { name: m.label, exact: true }).locator('[data-slot="mark"] svg')).toHaveCount(1)
}
const key = bar.getByRole('link', { name: 'Get API Key' })
await expect(key).toHaveAttribute('href', GET_KEY.href)
await expect(key).toHaveAttribute('target', '_blank')
await expect(key).not.toHaveAttribute('aria-expanded')
})
test('every menu opens on hover and on a click, closes on Escape and outside, and every row answers', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`, { waitUntil: 'load' })
const bar = page.getByRole('banner')
// Pricing is one page: a link to it, no menu.
for (const link of LINKS) await expect(bar.getByRole('link', { name: link.label, exact: true })).toHaveAttribute('href', link.href)
const seen: string[] = []
for (const menu of OPENS) {
const trigger = bar.getByRole('button', { name: menu.label, exact: true })
await open(page, trigger, menu.id)
const plane = page.locator(`#menu-${menu.id}`)
// A group's title is the first text that reads it: Learn's lead group is
// named for the one row it holds (1b82520f), and the row comes after it.
for (const group of menu.groups) await expect(plane.getByText(group.title, { exact: true }).first()).toBeVisible()
seen.push(...(await plane.getByRole('link').evaluateAll((rows) => rows.map((r) => r.getAttribute('href') ?? ''))))
// The plane spans the window and nothing leaves it sideways.
const box = (await plane.boundingBox())!
expect(Math.round(box.x), 'the plane starts at the window edge').toBe(0)
expect(Math.round(box.width), 'and spans it').toBe(await page.evaluate(() => document.documentElement.clientWidth))
await page.keyboard.press('Escape')
await expect(plane).toHaveCount(0)
}
expect(new Set(seen), 'the bar shows every row the tree names').toEqual(new Set(ROWS.map((r) => r.href)))
expect(unanswered(seen)).toEqual([])
// A click opens and keeps a menu; a press on the page closes it.
const models = bar.getByRole('button', { name: 'Models', exact: true })
await page.mouse.move(700, 700)
await models.click()
await expect(models).toHaveAttribute('aria-expanded', 'true')
await page.mouse.move(700, 800)
await page.mouse.click(700, 800)
await expect(page.locator('#menu-models')).toHaveCount(0)
// The keyboard reaches a row, and Escape hands focus back to the trigger.
await models.focus()
await page.keyboard.press('ArrowDown')
await expect(page.locator('#menu-models a').first()).toBeFocused()
await page.keyboard.press('Escape')
await expect(models).toBeFocused()
await expect(page.locator('#menu-models')).toHaveCount(0)
})
test('Log in is this site’s sign-in', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/`, { waitUntil: 'load' })
const login = page.getByRole('banner').getByRole('link', { name: 'Log in' })
await expect(login).toHaveAttribute('href', LOGIN.href)
await expect(page.locator('#menu-login')).toHaveCount(0)
})
test('the phone bar is the name, search and a menu, and the sheet holds the whole tree', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/`, { waitUntil: 'load' })
const bar = page.getByRole('banner')
await expect(bar.getByRole('link', { name: 'Hanzo AI home' })).toBeVisible()
await expect(bar.getByRole('button', { name: 'Search Hanzo or ask AI' })).toBeVisible()
await expect(bar.getByRole('button', { name: 'Models', exact: true })).toBeHidden()
const toggle = bar.getByRole('button', { name: 'Open menu' })
await expect(async () => {
await toggle.click()
await expect(page.getByRole('dialog', { name: 'Menu' })).toBeVisible({ timeout: 1000 })
}).toPass({ timeout: 15000 })
const sheet = page.getByRole('dialog', { name: 'Menu' })
expect(await page.evaluate(() => document.documentElement.style.overflow), 'the page under the sheet holds still').toBe('hidden')
for (const link of LINKS) {
const row = sheet.getByRole('link', { name: link.label, exact: true })
await expect(row).toHaveAttribute('href', link.href)
await expect(row.locator('[data-slot="mark"] svg')).toHaveCount(1)
}
const seen: string[] = []
for (const menu of OPENS) {
const section = sheet.locator('details', { has: page.locator('summary', { hasText: menu.label }) })
await section.locator('summary').click()
await expect(section).toHaveAttribute('open', '')
await expect(section.locator('summary [data-slot="mark"] svg')).toHaveCount(1)
seen.push(...(await section.getByRole('link').evaluateAll((rows) => rows.map((r) => r.getAttribute('href') ?? ''))))
}
expect(new Set(seen), 'the sheet holds every row the bar does').toEqual(new Set(ROWS.map((r) => r.href)))
await expect(sheet.getByRole('link', { name: 'Log in', exact: true })).toHaveAttribute('href', LOGIN.href)
await expect(sheet.getByRole('link', { name: /^Get API Key/ })).toHaveAttribute('href', GET_KEY.href)
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(0)
await page.keyboard.press('Escape')
await expect(sheet).toHaveCount(0)
await expect(bar.getByRole('button', { name: 'Open menu' })).toBeFocused()
expect(await page.evaluate(() => document.documentElement.style.overflow)).toBe('')
})
test('the footer is the short set, and the menus keep the rest', async ({ page }) => {
await page.goto(`${base}/pricing`, { waitUntil: 'load' })
const foot = page.locator('footer[data-slot="footer"]')
const hrefs = await foot.getByRole('link').evaluateAll((rows) => rows.map((r) => r.getAttribute('href') ?? ''))
const want = [
...FOOT.flatMap((menu) => [menu.href, ...menu.groups.flatMap((group) => group.links.map((link) => link.href))]),
...FOOT_LINE.map((link) => link.href),
]
// Row for row: a link FOOT names under two columns (the column's own page, or
// /kai under Research and Products) is drawn under both.
// The wordmark is home. Footer draws it as https://hanzo.ai; the bar draws /.
const home = (href: string) => href !== '/' && href !== 'https://hanzo.ai'
expect(hrefs.filter(home).sort()).toEqual(want.filter(home).sort())
// The menus keep the rest: what they link and FOOT does not, the footer never draws.
const rest = MENUS.flatMap((menu) => menu.groups.flatMap((group) => group.links.map((link) => link.href))).filter((href) => !want.includes(href))
expect(rest.length, 'the menus hold more than the footer').toBeGreaterThan(0)
expect(hrefs.filter((href) => rest.includes(href)), 'the footer is not the sitemap').toEqual([])
await expect(foot.getByRole('link', { name: 'Privacy choices', exact: true })).toHaveAttribute('href', '#choices')
})
test('nothing in the chrome pushes a page sideways', async ({ page }) => {
for (const [width, height] of [
[1920, 1080],
[1366, 768],
[1024, 768],
[820, 1180],
[390, 844],
]) {
await page.setViewportSize({ width, height })
await page.goto(`${base}/`, { waitUntil: 'load' })
const over = await page.evaluate(() => {
const bar = document.querySelector('header[data-slot="masthead"]')!
const edge = document.documentElement.clientWidth
return [...bar.querySelectorAll('a, button')]
.filter((el) => (el as HTMLElement).offsetParent !== null)
.filter((el) => el.getBoundingClientRect().right > edge + 1)
.map((el) => el.textContent?.trim() || el.getAttribute('aria-label'))
})
expect(over, `at ${width} every control stays inside the window`).toEqual([])
if (width < 1024) continue
// From a laptop up the row is one line: no control overlaps the next.
const crowded = await page.evaluate(() => {
const bar = document.querySelector('header[data-slot="masthead"]')!
const boxes = [...bar.querySelectorAll('nav[aria-label="Main"] > *, button[aria-label="Search Hanzo or ask AI"], a[href="/login"]')]
.map((el) => ({ name: el.textContent?.trim() || el.getAttribute('aria-label'), r: el.getBoundingClientRect() }))
.filter((b) => b.r.width > 0)
.sort((a, b) => a.r.left - b.r.left)
return boxes.slice(1).flatMap((b, i) => (b.r.left < boxes[i].r.right - 1 || Math.abs(b.r.top - boxes[i].r.top) > 2 ? [`${boxes[i].name} | ${b.name}`] : []))
})
expect(crowded, `at ${width} the bar's controls stand side by side on one line`).toEqual([])
}
})
})