mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
380 lines
23 KiB
TypeScript
380 lines
23 KiB
TypeScript
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')
|
||
})
|
||
})
|