Files

178 lines
9.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from '@playwright/test'
import { OUT, pages, read, serveExport } from './export'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { PITCH, 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 with the offer, the prompt, Platform 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, 5)).toEqual(['For agents', 'The prompt', 'Platform', 'Company', 'Pages'])
const agents = txt.slice(txt.indexOf('## For agents'), txt.indexOf('## The prompt'))
expect(agents, 'For agents says the offer in the owner’s words').toContain(PITCH)
expect(agents).toContain('https://hanzo.ai/agents#headless')
expect(txt, 'the prompt is lib/agents.ts, byte for byte').toContain(PROMPT)
expect(txt).not.toContain('[email protected]')
})
/** Mounted at hanzo.ai from another export: the checkout is hanzo-inc/pay's (lib/publish `PRIVATE`). */
const MOUNTED = ['/pay']
/** Where a hanzo.ai address lands in the export: the file itself, its page, or its directory's index. */
function served(path: string): boolean {
if (MOUNTED.some((m) => path === m || path.startsWith(`${m}/`))) return true
const base = join(OUT, decodeURIComponent(path))
return [base, `${base}.html`, join(base, 'index.html')].some((f) => existsSync(f) && statSync(f).isFile())
}
test('every hanzo.ai link in llms.txt is a file the export serves, and none is clipped or left escaped', () => {
const txt = read('llms.txt')
const links = [...new Set([...txt.matchAll(/https:\/\/hanzo\.ai(\/[^\s)`"'<>]*)/g)].map((m) => m[1].split('#')[0].split('?')[0].replace(/[.,;:]+$/, '')))]
expect(links.length, 'the page index is in it').toBeGreaterThan(500)
expect(links.filter((path) => !served(path)), 'linked in llms.txt and not in the export').toEqual([])
// A description clipped through a markdown link leaves `[name](https://ope...`.
expect(txt.match(/\]\(<?https?:\/\/[^)\s]*\.\.\./g) ?? [], 'a link cut mid-address').toEqual([])
expect(txt.match(/&#x?[0-9a-f]+;|&(?:amp|quot|lt|gt|apos);/gi) ?? [], 'an HTML entity a reader of plain text sees as is').toEqual([])
})
test('the prompt and /agents state no allowance figure for an agent account, and say where the live one is', () => {
// A wallet account's own allowance is not final (lib/agents.ts PITCH): the
// copy names GET /v1/billing/tier and /v1/billing/plans instead of a number.
expect(PROMPT).toContain(PITCH)
expect(PROMPT).toContain('https://api.hanzo.ai/v1/billing/tier')
expect(PROMPT).toContain('https://api.hanzo.ai/v1/billing/plans')
expect(PROMPT).toContain('GET https://api.hanzo.ai/v1 lists every capability')
expect(PROMPT).not.toMatch(/\b\d[\d,]*\s+(?:free\s+)?(?:requests|calls|decisions)\b/i)
const html = read('agents.html')
const head = html.slice(0, html.indexOf('</head>'))
expect(head).toContain(`<meta name="description" content="${PITCH}"/>`)
// The one figure on the page is a person's Free plan, said as that.
const figures = [...html.matchAll(/[^.>]*\b\d[\d,]*\s+(?:free\s+)?requests a day[^.<]*/g)].map((m) => m[0])
expect(figures.length).toBeGreaterThan(0)
for (const f of figures) expect(f, 'an allowance figure outside the Free plan sentence').toMatch(/person on the Free plan/)
})
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: sign up free, pay as you go"/>',
'<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 path and script, and the Developers menu and footer lead to it', () => {
const html = read('agents.html')
expect(html).toContain('id="headless"')
const headless = html.slice(html.indexOf('id="headless"'))
for (const step of ['/v1/iam/web3/verify', '/v1/account/keys', '/v1/billing/tier', '/v1/billing/crypto/options', '/v1/billing/crypto/deposit', '/v1/chat/completions']) {
expect(headless, step).toContain(step)
}
expect(html, 'the hero links the path').toContain('href="#headless"')
const home = read('index.html')
expect(home.match(/href="\/agents#headless"[^>]*>(?:<[^>]+>)*Agents: sign up free, pay as you go/g)?.length ?? 0).toBeGreaterThanOrEqual(1)
})
test('/agents opens on its offer on a phone: the title and the way in are drawn, not covered', async ({ page }) => {
const site = await serveExport()
try {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${site.url}/agents`)
const title = page.locator('h1')
await expect(title).toBeVisible()
for (const el of [title, page.locator('a[href="#headless"]').first()]) {
const box = (await el.boundingBox())!
expect(box.height, 'drawn at its own height').toBeGreaterThan(20)
// The topmost element at its centre is it, or inside it: nothing sits on it.
const own = await el.evaluate((node, [x, y]) => {
const hit = document.elementFromPoint(x, y)
return !!hit && (node === hit || node.contains(hit))
}, [box.x + box.width / 2, box.y + box.height / 2])
expect(own, 'covered by another element').toBe(true)
}
await expect(page.getByText(PITCH).first()).toBeVisible()
} finally {
await site.close()
}
})