Files
hanzo.ai/e2e/gates/palette.spec.ts
zeekay b29f60c059 Answer every URL Google knows at one address
Search Console had 5,812 hanzo.ai URLs as 404, 1,323 as duplicates
without a canonical and 165 as noindex. Three causes: the export shipped
a noindex page that forwarded in the browser at each retired address,
every page also answered at /x/, /x.html and www, and each page's .md
twin was an indexable copy of it.

Retired addresses are now 301s at the edge, rendered into hanzoai/universe
by scripts/edge.mjs from lib/edge.ts: MOVED grows the old product
taxonomy, the first site's routes, pages later dropped and the gateway's
old docs; SHELVES sends the rest of /docs/ to docs.hanzo.ai; and
lib/data/retired.json holds the 256 model pages the catalog has dropped,
read against the catalog each build renders. The shells go, except the
four that are Go import paths, which ?go-get=1 still reads.

The sitemap lists only pages at their own address, each with a lastmod
from its files' last commit, so deploy and gates check out the whole
history.
/privacy, /terms and /cookies stay up for the shared footer but point at
/legal/*, so they leave the sitemap. /pay and /v1 join the Disallow list.

Gates run the edge's rules over the export: every retired address lands
on a page in one hop, every page answers at one address, every sitemap
URL is final and dated, and no page links a redirect. scripts/crawl.mjs
asks production the same questions, and reads a Search Console export.
2026-09-29 21:34:44 -07:00

213 lines
9.2 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')
// 'Documentation', not 'Docs'. One destination carried both names — the
// header CTA said one, a nav row said the other, and the palette ranked
// them against each other. docs.hanzo.ai has a single name now, and this
// is it.
expect(docs[0]).toBe('Documentation')
// 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 sign-up, and Log in is a menu', async ({ page }) => {
const server = await serveExport()
try {
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(server.url + '/', { waitUntil: 'load' })
await hydrated(page)
// A LINK, not a button, so it goes somewhere before hydration and for anyone
// without JavaScript. Scoped to the BANNER: the page is free to carry a
// control of its own by the same name.
const pill = page.getByRole('banner').getByRole('link', { name: /^Try Hanzo/ })
// The sign-up is on this site, so it opens in place, the same door as the
// fold's Get started.
expect(await pill.getAttribute('href'), 'the pill opens the sign-up').toBe('/signup')
expect(await pill.getAttribute('target'), 'this site opens in place').toBeNull()
await expect(pill).not.toHaveAttribute('aria-expanded')
// Log in asks which product: the app, or the API platform.
const login = page.getByRole('banner').getByRole('button', { name: 'Log in' })
await login.click()
await expect(login).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('#menu-login a')).toHaveCount(2)
} finally {
await server.close()
}
})