mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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]>
203 lines
8.6 KiB
TypeScript
203 lines
8.6 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test'
|
|
import { serveExport } from './export'
|
|
import { HANZO_FLAGSHIP_PUBLIC } from '@hanzogui/shell/registry'
|
|
|
|
/**
|
|
* ⌘K.
|
|
*
|
|
* It is `@hanzogui/shell`'s, and it is asserted HERE because this repo is where
|
|
* it is wired to real data: the palette can only find /pricing because
|
|
* `components/home/shell.tsx` hands it `lib/data/pages.json`, and that file is
|
|
* written at prebuild by walking `app/`. A test in the package would prove the
|
|
* matcher works on a fixture; this proves the site is searchable.
|
|
*
|
|
* The palette's own doors group is one of the three lists it ranks and is
|
|
* asserted below. The header pill is NOT an entry point onto that group — it is a
|
|
* direct link to the Hanzo App, asserted at the end of this file.
|
|
*
|
|
* Against `out/` — the bytes that ship — because the whole chain has to hold:
|
|
* the walk found the routes, the snapshot got bundled, and the palette hydrated.
|
|
*/
|
|
|
|
const FIELD = 'input[placeholder="Search Hanzo"]'
|
|
const ROW = '[role="option"]'
|
|
|
|
/**
|
|
* Open the palette by the chord, which is the way most readers reach it.
|
|
*
|
|
* Retried until it takes. `load` fires when the bytes are down, and the ⌘K
|
|
* listener is attached by an effect some milliseconds later — so a single press
|
|
* races hydration and the whole suite goes red for a reason that is not the
|
|
* palette. Pressing only while the field is absent means a retry can never
|
|
* toggle a palette that just opened back shut.
|
|
*/
|
|
async function open(page: Page): Promise<void> {
|
|
await expect(async () => {
|
|
if ((await page.locator(FIELD).count()) === 0) await page.keyboard.press('Meta+k')
|
|
await expect(page.locator(FIELD)).toBeFocused({ timeout: 1000 })
|
|
}).toPass({ timeout: 15000 })
|
|
}
|
|
|
|
/**
|
|
* Wait for the page to answer its own keyboard.
|
|
*
|
|
* For the tests that click something. A click landing before hydration follows
|
|
* the anchor's real href and leaves the site, which fails as a missing element
|
|
* three lines later and reads like a broken selector.
|
|
*/
|
|
async function hydrated(page: Page): Promise<void> {
|
|
await open(page)
|
|
await page.keyboard.press('Escape')
|
|
await expect(page.locator(FIELD)).toHaveCount(0)
|
|
}
|
|
|
|
/** The titles the palette offers for `query`, in the order it ranks them. */
|
|
async function results(page: Page, query: string): Promise<string[]> {
|
|
await page.locator(FIELD).fill(query)
|
|
// The first row is re-selected on every query, so waiting for that is
|
|
// waiting for the render rather than for a duration.
|
|
await expect(page.locator(ROW).first()).toHaveAttribute('aria-selected', 'true')
|
|
return page.locator(ROW).evaluateAll((rows) =>
|
|
rows.map((row) => (row.querySelector('span:not([aria-hidden]) > span')?.textContent ?? '').trim())
|
|
)
|
|
}
|
|
|
|
test.describe('the command palette', () => {
|
|
test('finds pages, products and doors, ranks them, and never dead-ends', async ({ page }) => {
|
|
const server = await serveExport()
|
|
try {
|
|
await page.setViewportSize({ width: 1280, height: 900 })
|
|
await page.goto(server.url + '/', { waitUntil: 'load' })
|
|
await open(page)
|
|
|
|
// At rest it offers the doors — what most readers summon it to do.
|
|
//
|
|
// WHICH doors, and in what order, is `@hanzogui/shell`'s to decide and it
|
|
// says so: HANZO_FLAGSHIP_PUBLIC is the released estate in FLAGSHIP_ORDER,
|
|
// "stated here because this is where the menu's order is decided". So this
|
|
// reads that list rather than naming a product. It used to pin the lead as
|
|
// Hanzo Chat, and when 8.3.12 took Chat and Base out of the estate menu on
|
|
// purpose — "Chat and Base stay products and leave the estate menu" — the
|
|
// gate reported a decision as a regression and held the site's deploy shut
|
|
// for two days. A gate that names another repo's data ages into a liar.
|
|
//
|
|
// What is worth holding is that the doors ARRIVE: the palette's first rows
|
|
// are the estate's, in the estate's order, and there are some.
|
|
const doors = HANZO_FLAGSHIP_PUBLIC.map((p) => p.label)
|
|
expect(doors.length, 'the registry offers a stranger some doors').toBeGreaterThan(2)
|
|
const idle = await results(page, '')
|
|
expect(idle.slice(0, doors.length)).toEqual(doors)
|
|
|
|
// A page this site publishes. This is the whole defect: a products-only
|
|
// index answered "no results" about the page the header links above it.
|
|
expect((await results(page, 'pricing'))[0]).toBe('Pricing')
|
|
expect((await results(page, 'careers'))[0]).toBe('Careers')
|
|
|
|
// A product, named by the catalog.
|
|
expect((await results(page, 'datastore'))[0]).toBe('Datastore')
|
|
|
|
// A dropped letter still finds the word.
|
|
expect((await results(page, 'machins'))[0]).toBe('Machines')
|
|
expect((await results(page, 'datstore'))[0]).toBe('Datastore')
|
|
|
|
// A subsequence through prose is not a result: "docs" walks d·o·c·s
|
|
// through most sentences on the site, and answering that is what makes a
|
|
// search untrustworthy. The docs row, and almost nothing else.
|
|
const docs = await results(page, 'docs')
|
|
// The bar's door is Docs. It ranks ahead of the docs host's own name.
|
|
expect(docs[0]).toBe('Docs')
|
|
// A product whose page IS the docs — Sandbox, which has no page here —
|
|
// matches on its address, which is the one match a subsequence never is.
|
|
expect(docs.length).toBeLessThanOrEqual(4)
|
|
|
|
// No query is a dead end — whatever was typed can always be asked.
|
|
expect(await results(page, 'qqzzxx')).toEqual(['Ask AI: qqzzxx'])
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
test('walks on the arrows and leaves on Enter', async ({ page }) => {
|
|
const server = await serveExport()
|
|
try {
|
|
await page.setViewportSize({ width: 1280, height: 900 })
|
|
await page.goto(server.url + '/', { waitUntil: 'load' })
|
|
await open(page)
|
|
await results(page, 'pricing')
|
|
|
|
// The field keeps the caret, so the selection is announced from there or
|
|
// not at all.
|
|
const first = page.locator(ROW).first()
|
|
await expect(page.locator(FIELD)).toHaveAttribute(
|
|
'aria-activedescendant',
|
|
(await first.getAttribute('id')) ?? ''
|
|
)
|
|
|
|
await page.keyboard.press('ArrowDown')
|
|
await expect(first).toHaveAttribute('aria-selected', 'false')
|
|
await page.keyboard.press('ArrowUp')
|
|
await expect(first).toHaveAttribute('aria-selected', 'true')
|
|
|
|
await page.keyboard.press('Enter')
|
|
await expect(page).toHaveURL(/\/pricing$/)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
test('closes on Escape', async ({ page }) => {
|
|
const server = await serveExport()
|
|
try {
|
|
await page.setViewportSize({ width: 1280, height: 900 })
|
|
await page.goto(server.url + '/', { waitUntil: 'load' })
|
|
await open(page)
|
|
await page.keyboard.press('Escape')
|
|
await expect(page.locator(FIELD)).toHaveCount(0)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
test('is the whole screen on a phone', async ({ page }) => {
|
|
const server = await serveExport()
|
|
try {
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await page.goto(server.url + '/', { waitUntil: 'load' })
|
|
await hydrated(page)
|
|
await page.locator('button[aria-label="Search Hanzo or ask AI"]').click()
|
|
// A 576px card floated over a 390px phone is a card with wallpaper
|
|
// around it, so the panel spans the screen instead.
|
|
const box = (await page.locator('[role="dialog"]').boundingBox())!
|
|
expect(Math.round(box.width)).toBe(390)
|
|
expect(Math.round(box.height)).toBe(844)
|
|
// The page must not scroll sideways behind it.
|
|
const scroll = await page.evaluate(
|
|
() => document.documentElement.scrollWidth - window.innerWidth
|
|
)
|
|
expect(scroll).toBeLessThanOrEqual(0)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
test('the header pill is a real link to the key, and Log in is a link', async ({ page }) => {
|
|
const server = await serveExport()
|
|
try {
|
|
await page.setViewportSize({ width: 1280, height: 900 })
|
|
await page.goto(server.url + '/', { waitUntil: 'load' })
|
|
await hydrated(page)
|
|
|
|
const pill = page.getByRole('banner').getByRole('link', { name: /^Get API Key/ })
|
|
expect(await pill.getAttribute('href'), 'the pill opens the console keys').toBe('https://platform.hanzo.ai/api-keys')
|
|
expect(await pill.getAttribute('target'), 'the console opens in a new tab').toBe('_blank')
|
|
await expect(pill).not.toHaveAttribute('aria-expanded')
|
|
|
|
const login = page.getByRole('banner').getByRole('link', { name: 'Log in' })
|
|
await expect(login).toHaveAttribute('href', '/login')
|
|
await expect(page.locator('#menu-login')).toHaveCount(0)
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|