Files
hanzo.ai/e2e/gates/home.spec.ts

380 lines
23 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 { expect, test, type Page } from '@playwright/test'
import { read, serveExport } from './export'
import { MODELS, PRICING, block, serve } from './api'
import { api } from './revision'
import { FIELD } from './field'
import snapshot from '../../lib/data/benchmarks-kai.json' with { type: 'json' }
import { toRuns } from '../../lib/api/research'
import { BENCH } from '../../lib/coding'
import { clock, ms, tasks } from '../../lib/home'
import { DECISION, ahead, derive as kaiOf, table, type Run } from '../../lib/kai'
import { MOVED, NOINDEX } from '../../lib/publish'
import { JEV } from '../../lib/tally'
import { against } from './wins'
/** The coding board's runs, on whichever origin the page asks them of (lib/coding `runs`). */
const CODING = (u: URL): boolean => u.pathname === '/v1/research/runs' && u.searchParams.get('benchmark') === BENCH
/**
* THE LANDING, `/`: every figure on it is the one the page was served, it fits
* every screen, it says nothing in the console, it names no rival decision
* model and links no run record, it is offered to the index, and /home2, the
* address it was read at before it replaced `/`, forwards to it.
*
* The answers are invented (e2e/gates/api.ts, e2e/gates/field.ts and the
* prices and coding runs below), numbers no source holds, and the figures the
* page must print from them are written here BY HAND, so a wrong derivation in
* lib/home fails rather than agreeing with itself. The Decision Suite's count
* and the decision timings are lib/kai's (its own gates hold that derivation).
* The reduced-motion reader is the one measured, so every mark stands finished.
*/
/** The price list: the fixtures, Enso Pro beside them, and the single models of field.ts's GPQA board at invented rates. */
const LIST = {
...PRICING,
hanzoModels: [
...PRICING.hanzoModels,
{ name: 'enso-pro', fullName: 'enso-pro', description: 'Fixture: the pro preset.', features: [], tier: 'pro', owned_by: 'hanzo', family: 'enso', pricing: { input: 6, output: 27, cacheRead: null, cacheWrite: null } },
],
thirdPartyModels: [
...PRICING.thirdPartyModels,
{ id: 'anthropic/claude-opus-4.8', name: 'Anthropic: Claude Opus 4.8', provider: 'Anthropic', features: [], contextWindow: 1, isFree: false, pricing: { input: 8, output: 29, cacheRead: null, cacheWrite: null } },
{ id: 'lab/wide-5', name: 'Lab: Wide 5', provider: 'Lab', features: [], contextWindow: 1, isFree: false, pricing: { input: 3, output: 15, cacheRead: null, cacheWrite: null } },
{ id: 'lab/mid-4', name: 'Lab: Mid 4', provider: 'Lab', features: [], contextWindow: 1, isFree: false, pricing: { input: 1.5, output: 6, cacheRead: null, cacheWrite: null } },
{ id: 'lab/cheap-3', name: 'Lab: Cheap 3', provider: 'Lab', features: [], contextWindow: 1, isFree: false, pricing: { input: 0.2, output: 0.8, cacheRead: null, cacheWrite: null } },
// A variant of a board model is another product, and is never read for it.
{ id: 'anthropic/claude-opus-4.8:batch', name: 'Anthropic: Claude Opus 4.8 (batch)', provider: 'Anthropic', features: [], contextWindow: 1, isFree: false, pricing: { input: 1, output: 1, cacheRead: null, cacheWrite: null } },
],
}
/** The model list with Kai's and the other decision model's rates, so the Decision Suite's cost row holds and its count is stated. */
const RATES = { kai: 0.017, jev: 0.061 }
const SERVED = {
...MODELS,
data: [
...MODELS.data.map((m) => (m.id === 'kai' ? { ...m, pricing: { input_per_million: RATES.kai, output_per_million: 0 } } : m)),
{ id: JEV, owned_by: 'other', outputs: ['decision'], pricing: { input_per_million: RATES.jev, output_per_million: 0 } },
],
}
/** The research runs: the Kai snapshot's decision runs and the invented enso field. */
const RESEARCH = [...(snapshot.data as unknown as Run[]).filter((r) => !r.benchmark.startsWith('enso.')), ...FIELD]
/** Invented coding.repair runs: Claude Code as the baseline, Hanzo Dev's paired speed and two of its paths. */
const coded = (id: string, system: string, baseline: boolean, solved: number, median: number, calls: number, extra: object[] = []) => ({
id,
benchmark: 'coding.repair',
split: 'held',
system,
version: system,
baseline,
study: 'gate',
commit: 'f'.repeat(40),
ended: '2026-10-10T00:00:00Z',
notes: 'Fixture run.',
canonical: true,
visibility: 'public',
completion: 'complete',
measures: [
{ category: 'all', metric: 'solved', value: solved, n: 40, of: 40 },
{ category: 'all', metric: 'median_s', value: median },
{ category: 'all', metric: 'model_calls', value: calls },
...extra,
],
})
const repairs = (lo: number) => [
coded('coding.repair-held-gate-claude-code', 'claude-code', true, 40, 7.77, 4.4),
coded('coding.repair-held-gate-hanzo-dev', 'hanzo-dev', false, 39, 1.11, 1.3, [
{ category: 'vs-claude-code', metric: 'speedup', value: 4.444, lo, hi: 6.222, n: 39 },
{ category: 'vs-claude-code/search', metric: 'speedup', value: 9.876, lo: 8.1, hi: 11.2, n: 21 },
{ category: 'vs-claude-code/model', metric: 'speedup', value: 0.777, lo: 0.6, hi: 0.9, n: 18 },
]),
]
const REPAIRS = repairs(3.111)
/** The Decision Suite and its timings, as lib/kai and lib/home read the snapshot's runs. */
const KEPT = toRuns({ data: RESEARCH })!
const T = table(kaiOf(KEPT)!.suite, RATES)
const C = clock(T, KEPT)
/** Answer every read the page makes, as a reader who wants no motion. */
async function open(page: Page, url: string, width: number, coding: unknown[] = REPAIRS, path = '/') {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.setViewportSize({ width, height: width < 600 ? 844 : 900 })
await serve(page, { '/v1/models': SERVED, '/v1/pricing': LIST })
await page.route(api, (r) => r.fulfill({ json: { data: RESEARCH, total: RESEARCH.length } }))
await page.route(CODING, (r) => r.fulfill({ json: { data: coding } }))
await page.goto(`${url}${path}`, { waitUntil: 'load' })
await expect(page.locator('[data-api="loading"]')).toHaveCount(0, { timeout: 30_000 })
}
test.describe('the landing', () => {
let site: Awaited<ReturnType<typeof serveExport>>
test.beforeAll(async () => {
site = await serveExport()
})
test.afterAll(async () => site.close())
test('is offered to the index at https://hanzo.ai, and /home2 forwards to it', () => {
const html = read('index.html')
expect(html).not.toContain(NOINDEX)
expect(html).toContain('<link rel="canonical" href="https://hanzo.ai"/>')
expect(read('sitemap.xml')).toContain('<loc>https://hanzo.ai</loc>')
expect(read('sitemap.xml')).not.toContain('/home2')
expect(MOVED['/home2']).toBe('/')
})
test('the export names no rival decision model, and carries no figure that is read when the page opens', () => {
const html = read('index.html')
expect(html).not.toMatch(/jev|typesafe/i)
// The boards, the prices, the clock, the suite's count and the plans' cards are drawn only once their reads answer.
expect(html).not.toContain('Build what’s next')
expect(html).toContain('Pricing')
for (const mark of ['data-rate=', 'aria-label="Max"', 'data-row=', 'data-figure="enso"', 'data-figure="cheaper"', 'data-figure="suite"', 'data-figure="livecode"', 'data-slot="clock"']) expect(html).not.toContain(mark)
})
for (const width of [390, 1440, 1920]) {
test(`at ${width}: every figure is the served one, nothing leaves the screen, and the console is quiet`, async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`))
page.on('console', (m) => {
if (m.type() !== 'error') return
const at = m.location().url
if (at && !at.startsWith(site.url)) return
if (at && new URL(at).pathname.startsWith('/v1/')) return
const away = /(?:Access to \w+ at '|GET |POST )(https?:\/\/[^\s']+)/.exec(m.text())?.[1]
if (away && !away.startsWith(site.url)) return
errors.push(m.text())
})
await open(page, site.url, width)
// Research.Log is hidden from the landing because its benchmarks are shown in the dedicated sections above.
await expect(page.locator('section#research')).toHaveCount(0)
// The plans: the cards /pricing sells from, each at its served price. Max is one card whose pill picks
// the tier, and its price, its line and its way in follow the pick.
const plans = page.locator('#plans')
await expect(plans.getByRole('heading', { level: 2, name: 'Pricing' })).toBeVisible()
await expect(plans.getByRole('region', { name: 'Free', exact: true })).toContainText('Free')
await expect(plans.getByRole('region', { name: 'Pro', exact: true })).toContainText('$23')
// Every card's way in is Try Hanzo, with no commitment under it. No upgrade prompts on landing.
await expect(plans.getByRole('link', { name: 'Upgrade Plan →' })).toHaveCount(0)
await expect(plans.getByRole('button', { name: 'Upgrade to Business →' })).toHaveCount(0)
for (const name of ['Free', 'Pro', 'Max']) {
const c = plans.getByRole('region', { name, exact: true })
await expect(c.getByRole('link', { name: 'Try Hanzo', exact: true })).toHaveCount(1)
await expect(c).toContainText('No commitment · Cancel anytime')
}
const max = plans.getByRole('region', { name: 'Max', exact: true })
const tier = max.getByRole('group', { name: 'Max usage' })
await expect(tier.getByRole('button')).toHaveText(['5x', '20x'])
await expect(tier.getByRole('button', { name: 'Max 5x' })).toHaveAttribute('aria-pressed', 'true')
await expect(max).toContainText('From $111')
await expect(max).toContainText('5x more usage than Pro')
const next = async () => new URL(new URL((await max.getByRole('link', { name: 'Try Hanzo' }).getAttribute('href'))!, site.url).searchParams.get('next')!, site.url).searchParams.get('plan')
expect(await next()).toBe('max-5x')
await tier.getByRole('button', { name: 'Max 20x' }).click()
await expect(tier.getByRole('button', { name: 'Max 20x' })).toHaveAttribute('aria-pressed', 'true')
await expect(max).toContainText('$222')
await expect(max).toContainText('20x more usage than Pro')
await expect(max).not.toContainText('$111')
expect(await next()).toBe('max-20x')
// The questions follow the plans.
await expect(page.getByRole('heading', { name: 'FAQ' })).toBeVisible()
for (const q of ['What is Hanzo and how does it work?', 'Can I keep using Claude Code?', 'How much does it cost to use?']) await expect(page.getByText(q, { exact: true })).toBeVisible()
// The pill sits on the card, inside its edge, at every width.
const [card, pill] = await Promise.all([max.boundingBox(), tier.boundingBox()])
expect(pill!.x + pill!.width, 'the pill inside the card').toBeLessThanOrEqual(card!.x + card!.width)
expect(pill!.x, 'the pill inside the card').toBeGreaterThanOrEqual(card!.x)
// No figure divides two costs (Enso routed states each cost beside its own figure), and no link opens a research run.
await expect(page.locator('main')).not.toContainText(/per dollar/)
expect((await page.locator('main').innerText()).split('× less').length - 1, 'no multiple').toBe(0)
await expect(page.locator('main a[href*="/v1/research/runs"]')).toHaveCount(0)
// Nothing leaves the screen: no sideways scroll, and no line of text past the right edge outside a scrolling code block.
await page.mouse.wheel(0, 30_000)
await page.waitForTimeout(400)
const out = await page.evaluate(() => {
const w = document.documentElement.clientWidth
const past = [...document.querySelectorAll('main *')]
.filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent!.trim()))
.filter((e) => !e.closest('pre') && !e.closest('[data-models]'))
.filter((e) => {
const r = e.getBoundingClientRect()
return r.width > 0 && r.right > w + 1
})
.map((e) => `${e.tagName} "${e.textContent!.trim().slice(0, 40)}"`)
return { wide: document.documentElement.scrollWidth > w + 1, past }
})
expect(out.wide, `sideways scroll at ${width}`).toBe(false)
expect(out.past, `text past the edge at ${width}`).toEqual([])
// The rival decision model is never named on the page, in its text or its labels.
expect(await page.locator('main').innerText()).not.toMatch(/\bjev\b|typesafe/i)
expect(await page.locator('main [aria-label]').evaluateAll((es) => es.map((e) => e.getAttribute('aria-label')).join(' '))).not.toMatch(/\bjev\b|typesafe/i)
expect(errors).toEqual([])
})
}
test('a window moves by its title bar on a wide screen, its bar holds no control, and it stays put on a phone', async ({ page }) => {
await open(page, site.url, 1440)
const win = page.locator('[data-win="Hanzo.Start"]')
const bar = win.locator('[data-handle]')
const before = (await win.boundingBox())!
const grip = (await bar.boundingBox())!
// Dragged by the bar (by its title: the glider's window overlaps the bar's far end), held inside its section.
await page.mouse.move(grip.x + 140, grip.y + grip.height / 2)
await page.mouse.down()
await page.mouse.move(grip.x - 60, grip.y + grip.height / 2 + 30, { steps: 8 })
await page.mouse.up()
const moved = (await win.boundingBox())!
expect(before.x - moved.x, 'moved left by the drag').toBeGreaterThan(150)
expect(moved.y - before.y, 'moved down by the drag').toBeGreaterThan(20)
// The arrow keys nudge it while the bar has focus.
await bar.focus()
await page.keyboard.press('ArrowRight')
expect(((await win.boundingBox())!.x - moved.x)).toBeCloseTo(12, 0)
// The bar is a handle and nothing else: no button sits in any window's title bar, and what a window holds stays a control of its own.
expect(await page.locator('[data-win] [data-handle] button').count()).toBe(0)
await expect(win.getByRole('button', { name: /^Copy: / }).first()).toBeVisible()
// On a phone the windows stay where the page put them.
await page.setViewportSize({ width: 390, height: 844 })
await page.reload({ waitUntil: 'load' })
const still = page.locator('[data-win="Hanzo.Start"]')
await still.scrollIntoViewIfNeeded()
const at = (await still.boundingBox())!
const handle = (await still.locator('[data-handle]').boundingBox())!
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
await page.mouse.down()
await page.mouse.move(handle.x + handle.width / 2 - 120, handle.y + handle.height / 2, { steps: 6 })
await page.mouse.up()
expect((await still.boundingBox())!.x).toBeCloseTo(at.x, 0)
})
for (const width of [390, 1440]) {
test(`at ${width}: /dev races Hanzo Dev beside Claude Code on the served runs`, async ({ page }) => {
await open(page, site.url, width, REPAIRS, '/dev')
// The race: the served runs, not the build's snapshot.
await expect(page.locator('#race-title')).toHaveText('4.44× faster on the same coding tasks.')
await expect(page.locator('[data-slot="lane-Claude Code"] [data-clock]')).toHaveText('7.77 s')
await expect(page.locator('[data-slot="lane-Hanzo Dev"] [data-clock]')).toHaveText('1.11 s')
// Claude Code solved more (40 against 39), so no solved count is drawn on either side (lib/coding `ahead`).
await expect(page.locator('section#race')).not.toContainText('40 of 40')
await expect(page.locator('section#race')).not.toContainText('39 of 40')
await expect(page.locator('[data-slot="lane-Hanzo Dev"]')).toContainText('replay of run coding.repair-held-gate-hanzo-dev')
await expect(page.locator('[data-figure="speed"]')).toHaveText('4.44× faster over the 39 tasks both solved, 95% interval 3.11–6.22×.')
// The paths that are resolved gains, and not the slower one.
await expect(page.locator('[data-path]')).toHaveCount(1)
await expect(page.locator('[data-path="Settled by search, no model call"]')).toContainText('21 tasks')
await expect(page.locator('[data-path="Settled by search, no model call"]')).toContainText('9.88× · 95% 8.10–11.20×')
await expect(page.locator('section#race')).not.toContainText('A model wrote the edit')
await expect(page.locator('section#race')).not.toContainText('0.78×')
})
test(`at ${width}: /kai times one decision on the served runs, and no hosted call beside it`, async ({ page }) => {
await open(page, site.url, width, REPAIRS, '/kai')
// Decisions in a flash: the cost row's GPU timing and the served run, cached, runtime and HTTP, and the suite's rated tasks Kai wins; nothing hosted, nothing of Zen.
expect(C, 'the snapshot holds the cost row').not.toBeNull()
await expect(page.locator('#flash-title')).toHaveText('Decisions in a flash.')
for (const v of [C!.cached, C!.same, C!.fresh].filter((v): v is number => v != null)) await expect(page.locator('[data-slot="clock"]')).toContainText(`${ms(v)} ms`)
await expect(page.locator('[data-slot="clock"]')).not.toContainText('CPU')
await expect(page.locator('section#flash'), 'no hosted call, no cached-decision or in-process talk').not.toContainText(/hosted|cached decision|in process|Zen 6/)
await expect(page.locator('[data-slot="suite"] [data-task]').first()).toContainText('Jev')
await expect(page.locator('section#speed'), "Kai's page carries Kai's section, not Zen's").toHaveCount(0)
})
}
test("in the dark, every maker's mark on the GPQA chart is drawn in the page's ink, and Enso's H in the ground on an ink disc", async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' })
await open(page, site.url, 1440, REPAIRS, '/enso')
const plot = page.locator('[data-chart="gpqa"] svg[data-plot]').first()
await expect(plot.locator('[data-dot="opus-4.8"] foreignObject svg')).toHaveCount(1)
await expect(plot.locator('[data-dot="enso-ultra"] foreignObject svg')).toHaveCount(1)
const marks = await plot.locator('[data-dot]').evaluateAll((gs) => {
const ink = getComputedStyle(gs.find((g) => g.getAttribute('data-kind') === 'ours')!.querySelector(':scope > text')!).fill
return gs.flatMap((g) => {
const svg = g.querySelector('foreignObject svg')
const disc = g.querySelectorAll('circle')
return svg ? [{ id: g.getAttribute('data-dot'), ours: g.getAttribute('data-kind') === 'ours', mark: getComputedStyle(svg).color, disc: getComputedStyle(disc[disc.length - 1]).fill, ink, ground: getComputedStyle(document.body).backgroundColor }] : []
})
})
expect(marks.filter((m) => !m.ours).length, "a lab's mark is drawn").toBeGreaterThan(0)
for (const m of marks) {
expect(m.mark, `${m.id} stands out of its disc`).not.toBe(m.disc)
if (m.ours) expect(m.disc, `${m.id}'s disc is the page's ink`).toBe(m.ink)
else {
expect(m.mark, `${m.id} in the page's ink`).toBe(m.ink)
expect(m.mark, m.id!).not.toBe(m.ground)
}
}
})
test('the landing shows all language and creative models, six stack tiles, and the key', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await serve(page)
await page.goto(`${site.url}/`, { waitUntil: 'load' })
await page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
await expect(page.locator('#products')).toHaveCount(0)
await expect(page.locator('section#open-source')).toHaveCount(1)
for (const slug of ['enso', 'kai', 'zen', 'koma', 'iro', 'mori', 'oto', 'koe', 'uta']) {
await expect(page.locator(`[data-models] [data-model="${slug}"]`)).toHaveAttribute('href', `/${slug}`)
}
for (const slug of ['kata']) {
await expect(page.locator(`[data-models] [data-model="${slug}"]`)).toHaveCount(0)
}
for (const href of ['/gateway', '/agents', '/machines', '/base', '/commerce', '/o11y']) {
await expect(page.locator(`section#platform a[href="${href}"]`)).toHaveCount(1)
}
await expect(page.locator('section#platform a[href="/mcp"]')).toHaveCount(0)
await expect(page.locator('section#platform a[href="/search"]')).toHaveCount(0)
await expect(page.locator('section#platform a[href="/dev"]')).toHaveCount(0)
const key = page.locator('#get-key')
await key.scrollIntoViewIfNeeded()
await expect(key.getByRole('link', { name: 'Get API Key' })).toHaveAttribute('href', 'https://platform.hanzo.ai/api-keys')
})
test('a coding answer that no longer holds a resolved gain takes the race down', async ({ page }) => {
await open(page, site.url, 1440, repairs(0.9), '/dev')
await expect(page.locator('#race')).toHaveCount(0, { timeout: 15_000 })
})
test('with api.hanzo.ai down, nothing stands in for a figure, nothing is left reading and no section stands empty', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.setViewportSize({ width: 1440, height: 900 })
await block(page)
await page.route(api, (r) => r.abort('connectionrefused'))
await page.route(CODING, (r) => r.abort('connectionrefused'))
await page.goto(`${site.url}/`, { waitUntil: 'load' })
await expect(page.locator('[data-api="loading"]')).toHaveCount(0, { timeout: 30_000 })
await expect(page.locator('section#research')).toHaveCount(0)
await expect(page.locator('[data-slot="clock"]')).toHaveCount(0)
// The plans say their source is unreachable rather than show an older price.
await expect(page.locator('#plans [data-api="unreachable"]')).toHaveCount(1)
// The sign-up, the commands and the six stack tiles need no read. The
// product strip is not on this landing.
await expect(page.locator('[data-slot="start"]')).toContainText('curl -fsSL https://hanzo.sh | sh')
await expect(page.locator('[data-product]')).toHaveCount(0)
for (const href of ['/gateway', '/agents', '/machines', '/base', '/commerce', '/o11y']) {
await expect(page.locator(`section#platform a[href="${href}"]`)).toHaveCount(1)
}
})
test('animates only once seen, and plays the recorded medians', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await serve(page, { '/v1/models': SERVED, '/v1/pricing': LIST })
await page.route(api, (r) => r.fulfill({ json: { data: RESEARCH, total: RESEARCH.length } }))
await page.route(CODING, (r) => r.fulfill({ json: { data: REPAIRS } }))
await page.goto(`${site.url}/dev`, { waitUntil: 'load' })
await expect(page.locator('#race-title')).toHaveText('4.44× faster on the same coding tasks.', { timeout: 15_000 })
// Off screen, the race waits at zero.
await expect(page.locator('[data-slot="lane-Hanzo Dev"] [data-clock]')).toHaveText('0.00 s')
await page.locator('[data-slot="race"]').scrollIntoViewIfNeeded()
// On screen, each clock runs to its median and stops there.
await expect(page.locator('[data-slot="lane-Hanzo Dev"] [data-clock]')).toHaveText('1.11 s', { timeout: 8_000 })
await expect(page.locator('[data-slot="lane-Claude Code"] [data-clock]')).toHaveText('7.77 s', { timeout: 15_000 })
await expect(page.locator('[data-slot="lane-Claude Code"]')).toContainText('done')
})
})