mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
The sign-in: /login and /signup draw the password beside the address (login: the account's, autocomplete current-password;
signup: one to choose, at least 8 characters), a code one press away. IAM is asked about the address on the press: on
/login a new one goes to /signup before anything is sent; on /signup a known one logs in with its password at once (the
'You already have an account / Log in / Use a different email' bounce is gone), one that signs in by code alone is sent
one and told so, and a new one proves itself with the code, agrees to the terms, and is made with the password it chose
(iam.signup); a login page's password never makes an account (createAccountWithCode). lib/auth/first: login → password,
signup → create, no-password apps → code. The login, sale and first gates follow it.
The beta strip (app/layout.tsx): each diagonal edge is a honed bevel, a lit facet rising to a bright edge, the ground
side in shadow and a second facet's thin line behind it, over a brushed grain along the blade; both layers painted once
and only moved, as before.
The home: #speed is Zen 6's alone, prefill beside decode on the three desks, each figure to its own scale; #flash
('Decisions in a flash.', components/home/Flash) is Kai's: the clock's three lanes, cached, runtime and served over
HTTP, drawn to scale with no hosted call beside them and no 'cached decision' or 'in process' talk, and the Decision
Suite's won tasks; the other side is named on /kai (Flash named) and 'the baseline' on the landing, which names no rival
decision model. Order: Products, Models, Speed, Race, Flash, Pareto, Benchmarks. The research card says one decision's
served p50. components/home/lane.tsx is the shared bar.
The trolley problem (lib/data/benchmarks-trolley.json → lib/trolley → INDEX.trolley): kai-1.8 through nio/harm@1 at
floor .9 on Zach Landes's benchmark beside Jev's and GPT-6 Luna's recorded answers, only the checks Kai leads Jev on
drawn (dominated choices, stranger-not-server pairs, the switch onto one person), and the sealed ground-8 families, five
of them (8,300 scenes) decided right every time with no wrong course: /benchmarks#trolley (Showcase Trolley, blocks
kai-trolley and kai-trolley-sealed) and a landing card. The benchmarks and reads gates hold the rows to the file.
Gates aligned with what ships: the rail's places (Library, Templates, Scheduled tasks…) and Dev's mark for Customize;
the sidebar's Search opens the palette; the build's own suggestions call (@hanzo/build lib/suggest.js) is counted apart
from the chat's; /kai's hero as its copy now reads; the lane's opening as @hanzo/build 0.2.69 draws it; the home's suite
figure in the Flash section. Still failing, not of this change: app 'every kind of address' (the account menu's 'Usage
remaining' under @hanzo/rooms 0.1.84), the Recents view menu staying open after a pick, and e2e/unit/refusal.spec.ts
against @hanzo/rooms 0.1.84's planRequired.
394 lines
25 KiB
TypeScript
394 lines
25 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).toContain('Build what’s next')
|
||
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: 'Build what’s next' })).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 products are Chat, Dev, Bot, Studio and Team, the Studio footage moves only for a reader who wants motion, and the models and the platform follow', 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' })
|
||
const grid = page.locator('#products')
|
||
await grid.scrollIntoViewIfNeeded()
|
||
for (const [key, name, href] of [['chat', 'Chat', '/chat'], ['dev', 'Dev', '/dev'], ['bot', 'Bot', 'https://hanzo.bot'], ['studio', 'Studio', '/studio'], ['team', 'Team', /team/]] as const) {
|
||
const tile = page.locator(`[data-product="${key}"]`)
|
||
await expect(tile).toContainText(name)
|
||
await expect(tile.getByRole('link', { name: /Explore/ })).toHaveAttribute('href', href)
|
||
}
|
||
// Every model opens onto its own page over its painting; Kata is a tool, not a model. The platform's parts link to theirs.
|
||
for (const slug of ['zen', 'enso', 'kai', 'koma', 'iro', 'mori', 'oto', 'koe', 'uta']) {
|
||
const panel = page.locator(`[data-models] [data-model="${slug}"]`)
|
||
await expect(panel).toHaveAttribute('href', `/${slug}`)
|
||
if (slug !== 'koma') await expect(panel.locator('img[data-media]')).toHaveAttribute('src', `/images/models/${slug}.webp`)
|
||
}
|
||
await expect(page.locator('[data-models] [data-model="kata"]')).toHaveCount(0)
|
||
for (const href of ['/gateway', '/agents', '/mcp', '/search', '/machines', '/base', '/commerce', '/o11y']) await expect(page.locator(`section#platform a[href="${href}"]`)).toHaveCount(1)
|
||
// The platform names its parts, not the apps or models above it; the open-source core opens each repository.
|
||
await expect(page.locator('section#platform a[href="/dev"]')).toHaveCount(0)
|
||
for (const repo of ['hanzoai/cloud', 'hanzoai/base', 'hanzoai/iam', 'hanzoai/ai']) await expect(page.locator(`section#open-source a[href="https://github.com/${repo}"]`)).toHaveCount(1)
|
||
await expect(page.locator('section#open-source a[href^="https://github.com/zap-proto/"]')).toHaveCount(0)
|
||
// A–Z by default; the reader can sort by the most recently updated.
|
||
const names = () => page.locator('section#open-source a[href^="https://github.com/hanzoai/"]').evaluateAll((as) => as.map((a) => a.getAttribute('href')!))
|
||
const az = await names()
|
||
expect(az).toEqual([...az].sort())
|
||
await page.locator('section#open-source').getByRole('button', { name: 'Recently updated' }).click()
|
||
expect(await names()).not.toEqual(az)
|
||
await expect(page.locator('section#open-source a[href="/open-source"]')).toHaveCount(1)
|
||
await page.locator('#products').scrollIntoViewIfNeeded()
|
||
const moving = (sel: string) => page.locator(sel).first().evaluate((el) => el.getAnimations({ subtree: true }).filter((a) => a.playState === 'running').length)
|
||
await expect.poll(() => moving('[data-reel]'), { message: 'the Studio reel runs', timeout: 20_000 }).toBeGreaterThan(0)
|
||
// A reader who asked for less motion is shown the first still.
|
||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||
await page.reload({ waitUntil: 'load' })
|
||
await page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
|
||
await page.locator('#products').scrollIntoViewIfNeeded()
|
||
await page.waitForTimeout(1500)
|
||
expect(await moving('[data-reel]')).toBe(0)
|
||
})
|
||
|
||
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 products need no read.
|
||
await expect(page.locator('[data-slot="start"]')).toContainText('curl -fsSL https://hanzo.sh | sh')
|
||
await expect(page.locator('[data-product]')).toHaveCount(5)
|
||
})
|
||
|
||
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')
|
||
})
|
||
})
|