mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
robots.txt states its Content Signals in both groups: search, ai-input and ai-train, which the groups already allow. /auth.md says how an agent gets a credential today (wallet sign-in, device flow, an API key) and that the auth.md registration protocol is not implemented. /.well-known/mcp/server-card.json describes the MCP endpoint at api.hanzo.ai/v1/mcp (SEP-2127, protocol 2026-07-28), and the API catalog names it. skill.mjs writes /skill.md again as an Agent Skill with its index (Agent Skills Discovery 0.2.0), the digest taken from the bytes it writes. wellknown.mjs copies subdirectories and refuses a server card that does not point at the API host.
146 lines
7.6 KiB
TypeScript
146 lines
7.6 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
|
import { pages, read } from './export'
|
|
import { readFileSync } from 'node:fs'
|
|
import { createHash } from 'node:crypto'
|
|
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/)
|
|
for (const g of groups) expect(g, 'every group states the Content Signals').toMatch(/^Content-Signal: search=yes, ai-input=yes, ai-train=yes$/m)
|
|
})
|
|
|
|
test('/auth.md opens with the H1 a scanner reads and names only the doors that answer', () => {
|
|
const md = read('auth.md')
|
|
expect(md.startsWith('# auth.md\n')).toBe(true)
|
|
for (const door of [
|
|
'https://api.hanzo.ai/.well-known/oauth-protected-resource',
|
|
'https://hanzo.id/v1/iam/web3/verify',
|
|
'https://hanzo.id/v1/iam/oauth/device?client_id=hanzo-cli',
|
|
'https://api.hanzo.ai/v1/account/keys',
|
|
])
|
|
expect(md, door).toContain(door)
|
|
expect(md, 'says what it does not implement').toContain('## Not implemented')
|
|
})
|
|
|
|
test('/.well-known/mcp/server-card.json is a Server Card for the API host', () => {
|
|
const card = JSON.parse(read('.well-known/mcp/server-card.json'))
|
|
expect(card.$schema).toBe('https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json')
|
|
expect(card.name).toMatch(/^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/)
|
|
expect(card.description.length).toBeLessThanOrEqual(100)
|
|
expect(card.remotes).toEqual([{ type: 'streamable-http', url: 'https://api.hanzo.ai/v1/mcp', supportedProtocolVersions: ['2026-07-28'] }])
|
|
})
|
|
|
|
test('/.well-known/agent-skills/index.json names a skill whose digest is the bytes served', () => {
|
|
const index = JSON.parse(read('.well-known/agent-skills/index.json'))
|
|
expect(index.$schema).toBe('https://schemas.agentskills.io/discovery/0.2.0/schema.json')
|
|
expect(index.skills.length).toBeGreaterThan(0)
|
|
for (const s of index.skills) {
|
|
expect(s.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/)
|
|
expect(s.type).toBe('skill-md')
|
|
const body = read(s.url.replace(/^\//, ''))
|
|
expect(body.startsWith(`---\nname: ${s.name}\n`), `${s.url} names itself ${s.name}`).toBe(true)
|
|
expect(s.digest).toBe(`sha256:${createHash('sha256').update(body).digest('hex')}`)
|
|
}
|
|
})
|
|
|
|
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)
|
|
})
|