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