mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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.
213 lines
9.2 KiB
TypeScript
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()
|
|
}
|
|
})
|