mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Pricing is one page, so the bar's Pricing goes straight to it rather than opening Plans and API. An entry with no columns is a link, in the bar and in the phone sheet, with its mark; the chrome gate holds it to its page and opens only the entries that have menus. Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
243 lines
12 KiB
TypeScript
243 lines
12 KiB
TypeScript
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 { COMPANY, FOOT, FOOT_LINE, LEGAL, LOGIN, MENUS, TRY, 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 = [COMPANY, ...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',
|
|
'hips.hanzo.ai',
|
|
'status.hanzo.ai',
|
|
'platform.hanzo.ai',
|
|
'hanzo.app',
|
|
'hanzo.team',
|
|
'hanzo.agency',
|
|
'github.com',
|
|
'huggingface.co',
|
|
'hanzo.university',
|
|
'blog.hanzo.ai',
|
|
'hanzo.blog',
|
|
]
|
|
|
|
/** 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, ...LOGIN, TRY, ...LEGAL].map((l) => l.href))).toEqual([])
|
|
})
|
|
|
|
test('the bar reads Hanzo AI, six menus, search, then Log in and Try Hanzo', async ({ page }) => {
|
|
expect(MENUS.map((m) => m.label)).toEqual(['Research', 'Products', 'Solutions', 'Developers', 'Learn', 'Pricing'])
|
|
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' }),
|
|
bar.getByRole('button', { name: 'Company' }),
|
|
...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('button', { name: 'Log in' }),
|
|
bar.getByRole('link', { name: 'Try Hanzo' }),
|
|
]
|
|
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 menu 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 tryHanzo = bar.getByRole('link', { name: 'Try Hanzo' })
|
|
await expect(tryHanzo).toHaveAttribute('href', TRY.href)
|
|
await expect(tryHanzo).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 = menu === COMPANY ? bar.getByRole('button', { name: 'Company' }) : 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 research = bar.getByRole('button', { name: 'Research', exact: true })
|
|
await page.mouse.move(700, 700)
|
|
await research.click()
|
|
await expect(research).toHaveAttribute('aria-expanded', 'true')
|
|
await page.mouse.move(700, 800)
|
|
await page.mouse.click(700, 800)
|
|
await expect(page.locator('#menu-research')).toHaveCount(0)
|
|
|
|
// The keyboard reaches a row, and Escape hands focus back to the trigger.
|
|
await research.focus()
|
|
await page.keyboard.press('ArrowDown')
|
|
await expect(page.locator('#menu-research a').first()).toBeFocused()
|
|
await page.keyboard.press('Escape')
|
|
await expect(research).toBeFocused()
|
|
await expect(page.locator('#menu-research')).toHaveCount(0)
|
|
})
|
|
|
|
test('Log in offers the two products a person signs in to', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await page.goto(`${base}/`, { waitUntil: 'load' })
|
|
const login = page.getByRole('banner').getByRole('button', { name: 'Log in' })
|
|
await open(page, login, 'login')
|
|
const card = page.locator('#menu-login')
|
|
for (const row of LOGIN) await expect(card.getByRole('link', { name: new RegExp(`^${row.label}`) })).toHaveAttribute('href', row.href)
|
|
})
|
|
|
|
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: 'Research', 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)))
|
|
for (const row of LOGIN) await expect(sheet.getByRole('link', { name: new RegExp(`^${row.label}`) })).toHaveAttribute('href', row.href)
|
|
await expect(sheet.getByRole('link', { name: /^Try Hanzo/ })).toHaveAttribute('href', TRY.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.
|
|
const home = (href: string) => href !== '/'
|
|
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"], button[aria-label="Company"]')]
|
|
.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([])
|
|
}
|
|
})
|
|
})
|