Files
hanzo.ai/e2e/gates/agents.spec.ts
zeekay ec9fdc9510 head: every page names llms.txt as describedby, skill.md as markdown, and the API as WebAPI
The root head already pointed at /llms.txt as an alternate. It now also
says `describedby`, the relation api.hanzo.ai's Link header and the static
sites use, and names /skill.md as the page's markdown alternate. The JSON-LD
graph gains a schema.org WebAPI node: api.hanzo.ai/v1, documented by the
agent manifest at hanzoskills.com/hanzo.md, provided by the organization
node. The agents gate checks both links on every exported page and the node.
2026-10-26 13:56:24 -07:00

110 lines
5.7 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { pages, read } from './export'
import { readFileSync } from 'node:fs'
import { PROMPT } from '../../lib/agents'
/**
* WHAT AN AGENT READS ABOUT HANZO, READ BACK OFF THE EXPORT.
*
* The prompt is one copy (lib/agents.ts) drawn on /agents and written into
* llms.txt; the crawlers are named in robots.txt; the JSON-LD parses and says
* what the company is; the API catalog is a linkset. Each of these is a file a
* machine reads and a person never opens, so a regression in any of them is
* invisible on the page.
*/
/** Every JSON-LD block in one exported page, parsed. */
function ld(html: string): unknown[] {
return [...html.matchAll(/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]))
}
/** Every node of every block, with `@graph` flattened. */
function nodes(blocks: unknown[]): Record<string, unknown>[] {
return blocks.flatMap((b) => {
const o = b as Record<string, unknown>
return Array.isArray(o['@graph']) ? (o['@graph'] as Record<string, unknown>[]) : [o]
})
}
test('llms.txt opens with the positioning, then For agents, the prompt and Company', () => {
const txt = read('llms.txt')
expect(txt.startsWith("# Hanzo\n\n> The AI operating system for business. Building business software since 2014. Techstars '17.")).toBe(true)
const heads = [...txt.matchAll(/^## (.+)$/gm)].map((m) => m[1])
expect(heads.slice(0, 4)).toEqual(['For agents', 'The prompt', 'Company', 'Pages'])
expect(txt, 'the prompt is lib/agents.ts, byte for byte').toContain(PROMPT)
expect(txt).not.toContain('[email protected]')
})
test('robots.txt names the AI crawlers and holds them to the same Disallow lines', () => {
const txt = read('robots.txt')
for (const bot of ['GPTBot', 'ClaudeBot', 'anthropic-ai', 'PerplexityBot', 'Google-Extended', 'CCBot', 'Applebot-Extended']) {
expect(txt, bot).toMatch(new RegExp(`^User-Agent: ${bot}$`, 'm'))
}
const groups = txt.split(/\n\n+/).filter((g) => /^User-Agent:/m.test(g))
const disallow = (g: string) => [...g.matchAll(/^Disallow: (\S+)$/gm)].map((m) => m[1]).sort()
expect(groups.length).toBeGreaterThanOrEqual(2)
for (const g of groups) expect(disallow(g)).toEqual(disallow(groups[0]))
expect(txt).not.toMatch(/Bytespider/)
})
test('the root JSON-LD parses and says what Hanzo is, with no search box it cannot answer', () => {
const graph = nodes(ld(read('index.html')))
const org = graph.find((n) => n['@type'] === 'Organization')
expect(org).toBeTruthy()
expect(org!.foundingDate).toBe('2014')
expect(org!.description).toBe("The AI operating system for business. Building business software since 2014. Techstars '17.")
expect((org!.founder as { name: string }[]).map((f) => f.name)).toEqual(['Antje Worring', 'Zach Kelling'])
const site = graph.find((n) => n['@type'] === 'WebSite')
expect(site).toBeTruthy()
expect(site!.potentialAction, 'no SearchAction: /search reads no query').toBeUndefined()
const api = graph.find((n) => n['@type'] === 'WebAPI')
expect(api?.url).toBe('https://api.hanzo.ai/v1')
expect(api?.documentation, 'the agent manifest').toBe('https://hanzoskills.com/hanzo.md')
expect(api?.provider).toEqual({ '@id': org!['@id'] })
})
test('/agents carries the prompt and states its questions as FAQPage', () => {
const html = read('agents.html')
const faq = nodes(ld(html)).find((n) => n['@type'] === 'FAQPage')
expect(faq).toBeTruthy()
const asked = (faq!.mainEntity as { name: string }[]).map((q) => q.name)
expect(asked).toContain('What is free?')
expect(asked).toContain('How does an agent pay?')
for (const q of asked) expect(html, `the question "${q}" is drawn on the page`).toContain(q)
expect(html).toContain('href="https://docs.hanzo.ai/docs/agents"')
expect(html).toContain('href="/agents/sdk"')
})
test('/.well-known/api-catalog is an RFC 9727 linkset that names the OpenAPI document', () => {
const doc = JSON.parse(read('.well-known/api-catalog')) as { linkset: { anchor: string; 'service-desc'?: { href: string }[] }[] }
const api = doc.linkset.find((l) => l.anchor === 'https://api.hanzo.ai/v1')
expect(api?.['service-desc']?.[0]?.href).toBe('https://api.hanzo.ai/v1/openapi.json')
})
test('every page names llms.txt (alternate and describedby), skill.md, the agent start page, the API catalog and the OpenAPI document in its head', () => {
const heads = [
'<link rel="alternate" type="text/plain" href="/llms.txt" title="llms.txt"/>',
'<link rel="describedby" type="text/plain" href="/llms.txt"/>',
'<link rel="alternate" type="text/markdown" href="/skill.md" title="skill.md"/>',
'<link rel="help" href="/agents" title="For agents: get an API key in one call"/>',
'<link rel="api-catalog" href="/.well-known/api-catalog"/>',
'<link rel="service-desc" type="application/json" href="https://api.hanzo.ai/v1/openapi.json"/>',
]
// /email/* are the bodies of the emails the site sends: a mail client reads them, not an agent.
const shipped = pages().filter(({ route }) => !route.startsWith('/email/'))
expect(shipped.length).toBeGreaterThan(100)
const missing = shipped.flatMap(({ route, file }) => {
const head = readFileSync(file, 'utf8').split('</head>')[0]
return heads.filter((h) => !head.includes(h)).map((h) => `${route}: ${h}`)
})
expect(missing.slice(0, 20), `${missing.length} heads miss a pointer`).toEqual([])
})
test('/agents prints the headless script, and the Developers menu and footer lead with it', () => {
const html = read('agents.html')
expect(html).toContain('id="headless"')
expect(html).toContain('/v1/iam/web3/verify')
const home = read('index.html')
expect(home.match(/href="\/agents"[^>]*>(?:<[^>]+>)*Agents: get an API key in one call/g)?.length ?? 0).toBeGreaterThanOrEqual(1)
})