mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
178 lines
9.7 KiB
TypeScript
178 lines
9.7 KiB
TypeScript
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()
|
||
}
|
||
})
|