Files
hanzo.ai/e2e/gates/catalog.spec.ts
zeekay 550112084b The model count reads /v1/pricing/summary's providers as the map it is
The summary endpoint answers providers as lab -> counts where /v1/pricing's
own summary answers a number, so toSummary refused the live document and
/models drew a dash for all three counts. A map is counted by its labs (57).
The gates' fixture answers the live shape, and the unit test holds both.
2026-10-15 11:28:04 -07:00

167 lines
8.5 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { serveExport } from './export'
import { MODELS, PRICING, SUMMARY, block, serve } from './api'
/**
* /MODELS CARRIES THE CATALOGUE, and every number on it was read when the page
* opened rather than typed or baked.
*
* The page joins two reads in the browser (lib/models/catalog): the routing
* table (GET /v1/models) for what answers and a lab's rate, and the price list
* (GET /v1/pricing) for what ours cost. The gate serves both (e2e/gates/api)
* and reads every expectation off what it served, so a row can only show a
* number the page was handed.
*
* THE PAGE IS SHORT. Every model the routing table lists is a row, one per
* model page (`kai` and `hanzo/kai` are one).
*
* THE JOIN IS DEAD. Our own models arrive from the routing table with no rate,
* so the flagship reads "—" the moment the join to the price list breaks.
*
* THE COUNT DISAGREES WITH THE SITE. The opening line states the price list's
* count, and the table states no other.
*
* THE CONTROLS ARE DECORATION. A field that filters nothing and a chip that
* selects nothing both render perfectly.
*
* THE BACKEND IS DOWN. The table says so and shows nothing in its place.
*/
const money = (n: number) => `$${n.toFixed(n < 1 ? 3 : 2)}`
/** The price list's rate for a model of ours. */
const ourRate = (id: string) => PRICING.hanzoModels.find((m) => m.name === id)!.pricing
/** The routing table's rate for a lab's model. */
const labRate = (id: string) => MODELS.data.find((m) => m.id === id)!.pricing!
test.describe('the catalogue', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('every model the routing table lists has a row, ours at the price list rate and a lab at its own', async ({ page }) => {
const asked = await serve(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/models`, { waitUntil: 'load' })
const rows = page.locator('[data-model]')
// `kai` and `hanzo/kai` are one model and one page.
await expect(rows).toHaveCount(MODELS.data.length - 1)
expect(asked).toEqual(expect.arrayContaining(['/v1/models', '/v1/pricing']))
const enso = ourRate('enso')
await expect(page.locator('[data-model="enso"]')).toHaveText(new RegExp(`Enso.*enso.*\\${money(enso.input)}.*\\${money(enso.output)}`, 's'))
const kai = ourRate('kai')
await expect(page.locator('[data-model="kai"]')).toHaveText(new RegExp(`Kai.*kai.*\\${money(kai.input)}.*Free`, 's'))
await expect(page.locator('[data-model="hanzo/kai"]')).toHaveCount(0)
const opus = labRate('anthropic/claude-opus-4.6')
await expect(page.locator('[data-model="anthropic/claude-opus-4.6"]')).toContainText(money(opus.input_per_million))
await expect(page.locator('[data-model="anthropic/claude-opus-4.6"]')).toContainText(money(opus.output_per_million))
// A free model says Free, in words.
await expect(page.locator('[data-model="meta-llama/llama-4-scout:free"]')).toContainText('Free')
})
test("a model's own page states the rate its row does, read when it opens", async ({ page }) => {
await serve(page)
const specs = page.locator('#main')
const enso = ourRate('enso')
await page.goto(`${base}/models/hanzo/enso`, { waitUntil: 'load' })
await expect(specs).toContainText(`${money(enso.input)} in · ${money(enso.output)} out, per million tokens`)
await expect(specs).toContainText('Context Window')
const kai = ourRate('kai')
await page.goto(`${base}/models/hanzo/kai`, { waitUntil: 'load' })
await expect(specs).toContainText(`${money(kai.input)} in · Free out`)
const opus = labRate('anthropic/claude-opus-4.6')
await page.goto(`${base}/models/anthropic/claude-opus-4.6`, { waitUntil: 'load' })
await expect(specs).toContainText(`${money(opus.input_per_million)} in · ${money(opus.output_per_million)} out`)
})
test('the count is the price list count, and the table states no other', async ({ page }) => {
await serve(page)
await page.goto(`${base}/models`, { waitUntil: 'load' })
await expect(page.locator('[data-tally="totalModels"]')).toHaveText(String(SUMMARY.totalModels))
await expect(page.locator('[data-tally="providers"]')).toHaveText(String(Object.keys(SUMMARY.providers).length))
await expect(page.locator('[data-tally="freeModels"]')).toHaveText(String(SUMMARY.freeModels))
// Nothing is narrowed, so the table volunteers no tally of its own.
await expect(page.locator('[data-model]').first()).toBeVisible()
await expect(page.locator('[data-matched]')).toHaveCount(0)
})
test('a bare short name files as an alias, never as a model we trained', async ({ page }) => {
await serve(page)
await page.goto(`${base}/models`, { waitUntil: 'load' })
// `gpt-5` reaches this gateway with no prefix and no family. It is not ours.
await expect(page.locator('[data-model="gpt-5"]')).toHaveAttribute('data-lab', 'alias')
await expect(page.locator('[data-model="enso"]')).toHaveAttribute('data-lab', 'hanzo')
const ours = await page.locator('tr[data-lab="hanzo"][data-model]').evaluateAll((tr) => tr.map((t) => t.getAttribute('data-model')!))
const claimed = new Set(PRICING.hanzoModels.map((m) => m.name))
const strangers = ours.filter((id) => !claimed.has(id) && !/^(hanzo\/)?(enso|zen)(\d|-|$)/.test(id))
expect(strangers, `filed under Hanzo but neither ours nor claimed: ${strangers.join(', ')}`).toEqual([])
})
test('the field narrows, and a chip narrows to what it names', async ({ page }) => {
await serve(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/models`, { waitUntil: 'load' })
await page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
const rows = page.locator('[data-model]')
await expect(rows).not.toHaveCount(0)
const whole = await rows.count()
await page.locator('[data-search="models"]').fill('anthropic')
await expect.poll(() => rows.count()).toBeLessThan(whole)
await expect(rows).not.toHaveCount(0)
const missed = await rows.evaluateAll(
(tr, term) => tr.filter((t) => !`${t.textContent} ${t.getAttribute('data-lab')}`.toLowerCase().includes(term)).length,
'anthropic',
)
expect(missed, 'a row survived the field that does not match what was typed').toBe(0)
await expect(page.locator('tr[data-lab="anthropic"][data-model]')).not.toHaveCount(0)
await expect(page.locator('[data-matched]')).toHaveText(`${await rows.count()} models.`)
await page.locator('[data-search="models"]').fill('zzzznotamodel')
await expect(rows).toHaveCount(0)
await expect(page.locator('[data-matched]')).toHaveText('No model answers to that.')
await page.locator('[data-search="models"]').fill('')
await page.locator('[data-narrow="free"]').click()
await expect(rows).not.toHaveCount(0)
const paid = await rows.evaluateAll((tr) => tr.filter((t) => !t.textContent?.includes('Free')).length)
expect(paid, 'a row under the Free chip that does not say Free').toBe(0)
})
test('at 390 the rates are still readable and nothing scrolls sideways', async ({ page }) => {
await serve(page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${base}/models`, { waitUntil: 'load' })
const enso = page.locator('[data-model="enso"]')
await enso.scrollIntoViewIfNeeded()
await expect(enso).toContainText(money(ourRate('enso').output))
const sideways = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1)
expect(sideways, 'the page scrolls sideways on a phone').toBe(false)
const cells = await enso.locator('td').evaluateAll((td) => td.filter((t) => getComputedStyle(t).display !== 'none').length)
expect(cells, 'the context column is still taking room on a phone').toBe(3)
await expect(page.locator('[data-search="models"]')).toBeVisible()
await expect(page.locator('[data-narrow="free"]')).toBeVisible()
})
test('with api.hanzo.ai down the table says so, and no rate or count stands in', async ({ page }) => {
await block(page)
await page.goto(`${base}/models`, { waitUntil: 'load' })
await expect(page.locator('[data-api="unreachable"][role="alert"]')).toContainText('api.hanzo.ai is unreachable', { timeout: 20_000 })
await expect(page.locator('[data-model]')).toHaveCount(0)
await expect(page.locator('[data-tally="totalModels"]')).toHaveText('—')
})
})