Files
hanzo.ai/e2e/docs-links.spec.ts
zooqueen 75c4f1f641 One product answers with one name, route, command and docs page
The CLI lines on the product pages were written from the product name -- hanzo
dns zones, hanzo kms keys -- and several were not commands the binary has. So the
command moves into lib/product.ts, which assembles the rest from what is already
stated: the catalog names the product and its route, pages.json names what the
catalog does not carry, and products-metadata answers for docs, where null means
docs publishes nothing and the link drops.

Every CLI line in that table was run against the binary. A product absent from it
has no command and its page prints none; nothing composes a verb out of a name.
Both blocks that draw it import the Product type rather than declaring their own,
which leaves the table the only place a command can be written.

The catalog also moves out of package.json into pnpm-workspace.yaml, where pnpm
reads it, so the versions are stated once.
2026-09-06 23:18:24 -07:00

205 lines
8.2 KiB
TypeScript

// Every docs.hanzo.ai link this site emits must address the docs CONTENT tree.
//
// docs.hanzo.ai serves its MDX under /docs. It once also answered bare product
// paths (docs.hanzo.ai/mcp, /chat, /vector, …) through the vanity aliases in
// the docs repo's `apps/docs/public/_redirects` — a Cloudflare Pages file. The
// site moved to hanzoai/static, which does not read that file, and all 73 of
// those links started 404ing silently: no build breaks, no test fails, the
// anchor just lands on a "not found".
//
// This test is the tripwire. It reads source, not the network, so it runs
// anywhere and costs nothing. It cannot tell you that /docs/foo EXISTS — only
// the docs content tree can — but it does catch the whole class of bug above,
// which is what actually happened.
//
// Allowed paths on docs.hanzo.ai, and nothing else:
// (empty) or / — the docs marketing home
// /docs — the content tree root, and anything under it
// /reference — the interactive OpenAPI reference
// /blog/<slug> — a docs blog post
//
// Spelled as predicates below rather than as example URLs, because an example
// URL in a comment is itself a docs.hanzo.ai link and this file scans source.
import { test, expect } from '@playwright/test'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const SKIP = new Set([
'node_modules', '.git', 'out', 'dist', '.turbo', '.claude',
// Playwright writes its report and its failure contexts as markdown, which
// matches SOURCE — so a run that FAILED left its own error text on disk and
// the next run read those offenders back as if a component held them.
'playwright-report', 'test-results',
])
// Build outputs are not source, and next writes more than one of them: a
// `--distDir` run leaves `.next-c` beside `.next`, which this scan then read as
// if it were source. Stale bundles still carry the bare links this test was
// written to kill, so it went red locally for everyone and stayed red — and a
// test that is always failing is not a gate, it is noise people learn to skip.
const isBuildOutput = (entry: string) => entry.startsWith('.next')
const SOURCE = /\.(tsx?|jsx?|mdx?)$/
const LINK = /https:\/\/docs\.hanzo\.ai([^\s"'`)\]}<>,]*)/g
function sources(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (SKIP.has(entry) || isBuildOutput(entry)) continue
const full = path.join(dir, entry)
if (statSync(full).isDirectory()) sources(full, out)
else if (SOURCE.test(entry)) out.push(full)
}
return out
}
const ok = (p: string) =>
p === '' ||
p === '/' ||
p === '/reference' ||
p === '/sitemap.xml' ||
p === '/docs' ||
p.startsWith('/docs/') ||
p.startsWith('/blog/')
test('every docs.hanzo.ai link addresses the /docs content tree', () => {
const bad: string[] = []
for (const file of sources(ROOT)) {
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
for (const m of line.matchAll(LINK)) {
// Template holes (`${target}`) are resolved by the caller; the
// generator that builds them is asserted separately, below.
if (m[1].includes('${')) continue
if (!ok(m[1].split('#')[0].split('?')[0])) {
bad.push(`${path.relative(ROOT, file)}:${i + 1} ${m[0]}`)
}
}
})
}
// The message states the RULE, not a status. Nothing here makes a request:
// `docs.hanzo.ai/agents` answers 200 and still belongs on this list, because
// it is a `Moved` stub — noindex, meta-refresh — whose canonical names the
// real page. Claiming a 404 sent readers hunting for one that was not there.
expect(
bad,
'these docs.hanzo.ai links address something other than the /docs content tree.\n' +
'A bare path is either a redirect stub (follow its canonical) or nothing:\n' +
bad.join('\n'),
).toEqual([])
})
// Measured 404s, one request each. Neither gate above can see them: the shape
// is right, and docs.hanzo.ai's sitemap LISTS them — the sitemap is a claim
// about what should exist, so a path can sit in it and answer 404, which is why
// the existence gate below passes on every one of these.
//
// A RATCHET, and it may only shrink. Deleting an entry is how a page that docs
// has since published becomes linkable again; adding one is how a link that was
// measured dead stops coming back. Nothing here may be re-pointed by guess — a
// near-miss page looks like it worked.
const GONE = [
'/docs/dashboards',
'/docs/metrics',
'/docs/mpc',
'/docs/projects/hanzoai/desktop',
'/docs/projects/hanzoai/ingress',
'/docs/projects/hanzoai/insights',
'/docs/projects/hanzoai/kv',
'/docs/projects/hanzoai/o11y',
'/docs/projects/hanzoai/operator',
'/docs/projects/hanzoai/sql',
'/docs/projects/hanzoai/stream',
'/docs/projects/hanzoai/telemetry',
'/docs/projects/hanzoai/tunnel',
'/docs/projects/hanzoai/ui',
'/docs/pubsub',
'/docs/registry',
'/docs/services/engine',
'/docs/services/guard',
'/docs/services/iam/provider/idv/overview',
'/docs/services/mq',
'/docs/services/visor',
'/docs/skills',
]
test('no source links a docs page measured 404', () => {
const gone = new Set(GONE)
const bad: string[] = []
for (const file of sources(ROOT)) {
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
for (const m of line.matchAll(LINK)) {
const p = m[1].split('#')[0].split('?')[0].replace(/\/$/, '')
if (gone.has(p)) bad.push(`${path.relative(ROOT, file)}:${i + 1} ${m[0]}`)
}
})
}
expect(
bad,
'these addresses were asked for and answered 404. Drop the link, or write ' +
'the page in hanzo-docs and delete its line from GONE:\n' + bad.join('\n'),
).toEqual([])
})
test('docsUrl() builds a /docs path', async () => {
const { docsUrl, productsMetadata } = await import('../lib/constants/products-metadata')
for (const slug of Object.keys(productsMetadata)) {
const url = docsUrl(slug)
if (url === null) continue // product has no docs page — link is dropped
expect(url, `docsUrl('${slug}')`).toMatch(/^https:\/\/docs\.hanzo\.ai\/docs\/.+/)
}
})
// The two tests above check SHAPE, and say so. Shape is not enough: twelve links
// addressed /docs/… correctly and still 404ed, because a product page derived its
// identity from its display name ("Gas Manager" -> manager) and then asserted
// /docs/<that> existed. Worse, four pages whose names end in "API" all derived
// `api`, and /docs/api DOES exist — so they were wrong with a 200, which no
// status-code checker can ever see.
//
// This is the existence gate. It reads docs.hanzo.ai's sitemap (one request,
// ~2.7k paths) rather than probing each link, so it stays cheap as the site
// grows. It needs the network, so it is skipped when the fetch fails rather than
// turning an offline CI run red — a gate that cannot run must not lie either way.
test('every docs_slug names a page that docs.hanzo.ai actually publishes', async () => {
const { docsUrl, productsMetadata } = await import('../lib/constants/products-metadata')
let xml: string
try {
const res = await fetch('https://docs.hanzo.ai/sitemap.xml')
if (!res.ok) test.skip(true, `sitemap returned ${res.status}`)
xml = await res.text()
} catch {
test.skip(true, 'docs.hanzo.ai unreachable')
return
}
const published = new Set(
[...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) =>
m[1].replace(/^https:\/\/docs\.hanzo\.ai/, '').replace(/\/$/, ''),
),
)
expect(published.size, 'sitemap parsed').toBeGreaterThan(100)
const missing = Object.keys(productsMetadata)
.map((slug) => [slug, docsUrl(slug)] as const)
.filter(([, url]) => url !== null)
.map(([slug, url]) => [slug, url!.replace('https://docs.hanzo.ai', '')] as const)
.filter(([, path]) => !published.has(path))
.map(([slug, path]) => `${slug} -> ${path}`)
expect(
missing,
`these products link to docs pages that do not exist. Either write the page, ` +
`point docs_slug at one that exists, or set docs_slug: null:\n${missing.join('\n')}`,
).toEqual([])
})