mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every import, the catalog pin and the docs name @hanzo/app and github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and components/rooms.tsx is components/app.tsx.
497 lines
26 KiB
TypeScript
497 lines
26 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
import { AUTO, FREE as FREE_LANE, PAID, arrive, chip, kept, row } from './chat'
|
||
import { serveExport } from './export'
|
||
import { PLANS } from './api'
|
||
import { answer, signIn } from '../session'
|
||
|
||
/**
|
||
* PLAN USAGE, AS SHARES, NEVER AS FIGURES.
|
||
*
|
||
* `GET /v1/ai/limits` names a share per class of model — premium (third-party)
|
||
* and ours (Hanzo) — with its state and reset. The bars live on the usage
|
||
* settings only: /settings/organization/usage, and the rooms' Settings → Usage
|
||
* that the account menu and "See usage" open. /chat draws no bar and no near
|
||
* note; it says something only once the reader is turned away — a reply
|
||
* answered from a free model (`X-Hanzo-Fallback`) or a billing refusal — with
|
||
* one banner over the composer: what paused and until when, Upgrade, Add
|
||
* prepaid credit and See usage. The picker marks a paused class's models
|
||
* "Paused" and still picks them. No dollar figure, count or cap renders
|
||
* anywhere. Measured on the export, every call answered here, at a laptop and a
|
||
* phone, light and dark.
|
||
*/
|
||
|
||
const PERIOD_START = '2026-09-30T00:00:00Z'
|
||
const PERIOD_END = '2026-10-30T00:00:00Z'
|
||
const ACTIONS = [
|
||
{ kind: 'upgrade', label: 'Upgrade', plan: 'max-20x', url: 'https://hanzo.ai/pricing' },
|
||
{ kind: 'topup', label: 'Add prepaid credit', url: 'https://hanzo.ai/settings/organization/billing' },
|
||
]
|
||
const FREE_MODEL = { id: 'zen-free', name: 'Zen Free', family: 'zen', class: 'free' }
|
||
const THIRD = { id: 'claude-opus-4.8', name: 'Claude Opus 4.8', owned_by: 'anthropic', class: 'premium' }
|
||
|
||
type State = 'ok' | 'near' | 'limited'
|
||
const cls = (percent: number, state: State, paying = state === 'limited' ? 'none' : 'plan') => ({ percent, state, paying, resets_at: PERIOD_END })
|
||
|
||
const OK = {
|
||
plan: 'max-5x',
|
||
period_start: PERIOD_START,
|
||
period_end: PERIOD_END,
|
||
state: 'ok',
|
||
classes: { premium: cls(35, 'ok'), ours: cls(20, 'ok') },
|
||
actions: [],
|
||
upgrade: 'max-20x',
|
||
}
|
||
const NEAR = { ...OK, state: 'near', classes: { premium: cls(85, 'near'), ours: cls(20, 'ok') } }
|
||
const PREMIUM = {
|
||
...OK,
|
||
state: 'limited',
|
||
classes: { premium: cls(100, 'limited'), ours: cls(40, 'ok') },
|
||
limited: { reason: 'plan_allowance_used', classes: ['premium'], message: "You've used this period's included premium usage." },
|
||
actions: ACTIONS,
|
||
}
|
||
const OURS = {
|
||
...OK,
|
||
state: 'limited',
|
||
classes: { premium: cls(50, 'ok'), ours: cls(100, 'limited') },
|
||
limited: { reason: 'plan_allowance_used', classes: ['ours'], message: "You've used this period's included Hanzo usage." },
|
||
actions: ACTIONS,
|
||
}
|
||
const FREE = { plan: '', state: 'ok', classes: {}, actions: [{ kind: 'upgrade', label: 'Upgrade', url: 'https://hanzo.ai/pricing' }] }
|
||
|
||
/** A dollar figure, a count or a multiple beside usage words. */
|
||
const FIGURE = /\$\s?\d|\b\d[\d,]*\s*(requests?|messages?|tokens?)\b|\b\d+\s*x\b|\bof\s+\d[\d,]*\b/i
|
||
/** A plan's own name, "Max 5x", is the catalog's and states no figure; the words around it are measured. */
|
||
const NAMED = /\bMax \d+\s*x\b/g
|
||
const unnamed = (text: string) => text.replace(NAMED, 'Max')
|
||
|
||
/** GET /v1/ai/limits answers `next()` on every read; `reads` counts them. */
|
||
async function limits(page: Page, first: unknown, after: unknown = first) {
|
||
const heard = { reads: 0 }
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/ai/limits',
|
||
(route) => {
|
||
heard.reads++
|
||
return route.fulfill({ json: heard.reads === 1 ? first : after })
|
||
},
|
||
)
|
||
return heard
|
||
}
|
||
|
||
const SSE = [...'Done.'].map((c) => `data: ${JSON.stringify({ choices: [{ index: 0, delta: { content: c } }] })}\n\n`).join('') + 'data: [DONE]\n\n'
|
||
const EXPOSE = 'X-Hanzo-Usage, X-Hanzo-Usage-Class, X-Hanzo-Paid-By, X-Hanzo-Fallback, X-Hanzo-Usage-Reason, X-Hanzo-Served'
|
||
|
||
/** Every completion streams one short answer carrying `headers`. */
|
||
async function reply(page: Page, headers: Record<string, string> = {}) {
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/chat/completions',
|
||
(route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: 'text/event-stream',
|
||
headers: { 'Access-Control-Expose-Headers': EXPOSE, ...headers },
|
||
body: SSE,
|
||
}),
|
||
)
|
||
}
|
||
|
||
/** Every completion is refused with the billing envelope. */
|
||
async function refuse(page: Page, status: number, error: Record<string, unknown>, retry?: string) {
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/chat/completions',
|
||
(route) =>
|
||
route.fulfill({
|
||
status,
|
||
contentType: 'application/json',
|
||
headers: { 'Access-Control-Expose-Headers': `Retry-After, ${EXPOSE}`, ...(retry ? { 'Retry-After': retry } : {}) },
|
||
body: JSON.stringify({ error: { type: 'billing_error', actions: ACTIONS, ...error } }),
|
||
}),
|
||
)
|
||
}
|
||
|
||
async function theme(page: Page, scheme: 'dark' | 'light') {
|
||
await page.addInitScript((t) => localStorage.setItem('hanzo.appearance', JSON.stringify({ theme: t })), scheme)
|
||
await page.emulateMedia({ colorScheme: scheme })
|
||
}
|
||
|
||
async function send(page: Page, text: string) {
|
||
const field = page.locator('textarea').first()
|
||
await expect(field).toBeVisible({ timeout: 20_000 })
|
||
await field.fill(text)
|
||
await field.press('Enter')
|
||
}
|
||
|
||
const banner = (page: Page) => page.locator('[data-slot="limited-banner"]').filter({ visible: true }).first()
|
||
const wide = (page: Page) => page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1)
|
||
const ground = (page: Page) => page.evaluate(() => getComputedStyle(document.body).backgroundColor)
|
||
|
||
/** Text near a usage word that states a figure, anywhere in the rendered page. */
|
||
const figures = (page: Page) =>
|
||
page.evaluate(([source, named]) => {
|
||
const figure = new RegExp(source, 'i')
|
||
const plan = new RegExp(named, 'g')
|
||
const text = document.body.innerText
|
||
const out: string[] = []
|
||
for (const m of text.matchAll(/[^\n]*\b(usage|included|limit|limited|paused|credit)\b[^\n]*/gi)) if (figure.test(m[0].replace(plan, 'Max'))) out.push(m[0])
|
||
return out
|
||
}, [FIGURE.source, NAMED.source] as const)
|
||
|
||
test.describe('/chat says nothing about usage until the reader is turned away', () => {
|
||
test.use({ viewport: { width: 1440, height: 900 }, timezoneId: 'UTC' })
|
||
let base: string
|
||
let stop: () => Promise<void>
|
||
test.beforeAll(async () => {
|
||
const served = await serveExport()
|
||
base = served.url
|
||
stop = served.close
|
||
})
|
||
test.afterAll(async () => stop())
|
||
|
||
for (const scheme of ['dark', 'light'] as const)
|
||
for (const size of [
|
||
{ width: 1440, height: 900 },
|
||
{ width: 390, height: 844 },
|
||
])
|
||
for (const [name, body] of [
|
||
['ok', OK],
|
||
['near', NEAR],
|
||
['limited', PREMIUM],
|
||
] as const) {
|
||
test(`${name} at ${size.width}px, ${scheme}: no bar, no note, no banner in the room`, async ({ page }, info) => {
|
||
await page.setViewportSize(size)
|
||
await theme(page, scheme)
|
||
const heard = await limits(page, body)
|
||
await reply(page)
|
||
await arrive(page, base, 'pro', AUTO.id)
|
||
await expect.poll(() => heard.reads, { timeout: 20_000 }).toBeGreaterThan(0)
|
||
await expect(page.locator('textarea').filter({ visible: true }).first()).toBeVisible()
|
||
await expect(page.getByRole('progressbar').filter({ visible: true })).toHaveCount(0)
|
||
await expect(page.locator('[data-slot="usage-meter"], [data-slot="usage-near"], [data-slot="plan-usage"]')).toHaveCount(0)
|
||
await expect(banner(page)).toHaveCount(0)
|
||
expect(await figures(page)).toEqual([])
|
||
const rgb = (await ground(page)).match(/\d+/g)!.slice(0, 3).map(Number)
|
||
expect(rgb.reduce((a, b) => a + b, 0) / 3 > 128, `the ${scheme} theme painted`).toBe(scheme === 'light')
|
||
expect(await wide(page), 'no sideways scroll').toBe(false)
|
||
await page.screenshot({ path: info.outputPath(`chat-${name}-${size.width}-${scheme}.png`) })
|
||
})
|
||
}
|
||
|
||
for (const scheme of ['dark', 'light'] as const)
|
||
for (const size of [
|
||
{ width: 1440, height: 900 },
|
||
{ width: 390, height: 844 },
|
||
]) {
|
||
test(`a fallback reply at ${size.width}px, ${scheme}: one banner with Add credits, Upgrade and See usage`, async ({ page }, info) => {
|
||
await page.setViewportSize(size)
|
||
await theme(page, scheme)
|
||
const heard = await limits(page, OK, PREMIUM)
|
||
await reply(page, {
|
||
'X-Hanzo-Usage': 'limited',
|
||
'X-Hanzo-Usage-Class': 'premium',
|
||
'X-Hanzo-Paid-By': 'free',
|
||
'X-Hanzo-Fallback': FREE_MODEL.id,
|
||
'X-Hanzo-Usage-Reason': 'plan_allowance_used',
|
||
})
|
||
await arrive(page, base, 'pro', AUTO.id, [FREE_MODEL, THIRD])
|
||
await expect(banner(page)).toHaveCount(0)
|
||
await send(page, 'hello')
|
||
await expect(page.getByText('Done.', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
await expect(banner(page)).toContainText('Your Max 5x plan’s included premium model usage is used until Oct 30. You’re chatting on Zen Free.', { timeout: 20_000 })
|
||
for (const name of ['Add credits', 'Upgrade', 'See usage']) await expect(banner(page).getByRole('button', { name })).toBeVisible()
|
||
expect(heard.reads, 'read again on the fallback').toBeGreaterThan(1)
|
||
// Over the composer, inside the page, and at a laptop the message beside
|
||
// its actions. The composer is Dev's since @hanzo/app 0f921c5, a prose
|
||
// measure wide, so the message takes a second line there and no more.
|
||
const box = (await banner(page).boundingBox())!
|
||
const field = (await page.locator('textarea').filter({ visible: true }).first().boundingBox())!
|
||
expect(box.y, 'over the composer').toBeLessThan(field.y)
|
||
expect(box.x + box.width).toBeLessThanOrEqual(size.width)
|
||
if (size.width >= 1024) {
|
||
const words = (await banner(page).locator('[data-slot="limited-banner-message"]').boundingBox())!
|
||
const upgrade = (await banner(page).getByRole('button', { name: 'Upgrade' }).boundingBox())!
|
||
expect(upgrade.x, 'the actions beside the message, not under it').toBeGreaterThan(words.x + words.width)
|
||
expect(box.height, 'two lines at most').toBeLessThan(72)
|
||
}
|
||
const chrome = await banner(page).evaluate((el) => getComputedStyle(el).backgroundColor)
|
||
expect(chrome, 'neutral chrome').not.toMatch(/rgb\((2[0-9]{2}), (\d{1,2}), (\d{1,2})\)/)
|
||
await expect(page.getByRole('progressbar').filter({ visible: true })).toHaveCount(0)
|
||
expect(unnamed(await banner(page).innerText())).not.toMatch(FIGURE)
|
||
expect(await figures(page)).toEqual([])
|
||
expect(await wide(page), 'no sideways scroll').toBe(false)
|
||
await page.screenshot({ path: info.outputPath(`chat-fallback-${size.width}-${scheme}.png`) })
|
||
})
|
||
}
|
||
|
||
test('See usage opens the usage settings, where the bars are', async ({ page }, info) => {
|
||
await limits(page, OK, PREMIUM)
|
||
await reply(page, { 'X-Hanzo-Usage': 'limited', 'X-Hanzo-Usage-Class': 'premium', 'X-Hanzo-Fallback': FREE_MODEL.id, 'X-Hanzo-Usage-Reason': 'plan_allowance_used' })
|
||
await arrive(page, base, 'pro', AUTO.id, [FREE_MODEL])
|
||
await send(page, 'hello')
|
||
await banner(page).getByRole('button', { name: 'See usage' }).click()
|
||
const plan = page.locator('[data-slot="plan-usage"]').filter({ visible: true }).first()
|
||
await expect(plan).toBeVisible({ timeout: 20_000 })
|
||
// A class's bar is @hanzo/ui's UsageMeter row, keyed by its class, as the settings below read it.
|
||
await expect(plan.locator('[data-slot="usage-meter-row"][data-class="premium"]').getByRole('progressbar', { name: 'Premium models: 100% used' })).toHaveAttribute('aria-valuenow', '100')
|
||
await expect(plan).toContainText('Max 5x plan')
|
||
await page.screenshot({ path: info.outputPath('chat-see-usage.png') })
|
||
})
|
||
|
||
test('a free plan asking a paid model is answered on the free model, and the banner stays once the limits read again', async ({ page }, info) => {
|
||
// The live wire: X-Hanzo-Fallback names the lane, X-Hanzo-Served the model, and a
|
||
// free plan's limits still read ok, having no class to say it in.
|
||
const heard = await limits(page, { plan: 'free', state: 'ok', classes: {}, actions: ACTIONS })
|
||
await reply(page, { 'X-Hanzo-Usage': 'limited', 'X-Hanzo-Fallback': 'free', 'X-Hanzo-Served': FREE_MODEL.id, 'X-Hanzo-Usage-Reason': 'paid_plan_required' })
|
||
await arrive(page, base, 'free', AUTO.id, [FREE_MODEL, THIRD])
|
||
await send(page, 'hello')
|
||
await expect(page.getByText('Done.', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
await expect.poll(() => heard.reads, { message: 'read again on the fallback', timeout: 20_000 }).toBeGreaterThan(1)
|
||
await page.waitForTimeout(500)
|
||
await expect(banner(page)).toContainText("This model needs a paid plan. You're chatting on Zen Free.")
|
||
for (const name of ['Add credits', 'Upgrade', 'See usage']) await expect(banner(page).getByRole('button', { name })).toBeVisible()
|
||
expect(await figures(page)).toEqual([])
|
||
await page.screenshot({ path: info.outputPath('chat-fallback-paid-plan.png') })
|
||
})
|
||
|
||
test('Continue with credits comes first when the server offers it, turns the choice on, and asks again', async ({ page }, info) => {
|
||
const CREDITS = { kind: 'credits', label: 'Continue with credits', url: '/v1/ai/limits' }
|
||
await limits(page, OK)
|
||
const wrote: unknown[] = []
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/ai/limits',
|
||
(route) => {
|
||
if (route.request().method() !== 'PUT') return route.fallback()
|
||
wrote.push(route.request().postDataJSON())
|
||
return route.fulfill({ json: { ...OK, credits_after_allowance: true } })
|
||
},
|
||
)
|
||
const asked: string[] = []
|
||
let first = true
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/chat/completions',
|
||
(route) => {
|
||
asked.push(String((route.request().postDataJSON() as { model?: unknown } | null)?.model ?? ''))
|
||
if (first) {
|
||
first = false
|
||
return route.fulfill({
|
||
status: 402,
|
||
contentType: 'application/json',
|
||
headers: { 'Access-Control-Expose-Headers': EXPOSE },
|
||
body: JSON.stringify({ error: { type: 'billing_error', code: 'plan_allowance_used', message: 'm', class: 'ours', resets_at: PERIOD_END, actions: [CREDITS, ...ACTIONS] } }),
|
||
})
|
||
}
|
||
return route.fulfill({ status: 200, contentType: 'text/event-stream', headers: { 'Access-Control-Expose-Headers': EXPOSE }, body: SSE })
|
||
},
|
||
)
|
||
await arrive(page, base, 'pro', PAID.id)
|
||
await send(page, 'hello')
|
||
await expect(banner(page).getByRole('button', { name: 'Continue with credits' })).toBeVisible({ timeout: 20_000 })
|
||
const kinds = await banner(page).locator('[data-kind]').evaluateAll((els) => els.map((e) => e.getAttribute('data-kind')))
|
||
expect(kinds).toEqual(['credits', 'upgrade', 'usage'])
|
||
await page.screenshot({ path: info.outputPath('chat-credits-offered.png') })
|
||
await banner(page).getByRole('button', { name: 'Continue with credits' }).click()
|
||
await expect.poll(() => wrote).toEqual([{ creditsAfterAllowance: true }])
|
||
await expect(page.getByText('Done.', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
expect(asked, 'asked again on the model the reader picked').toEqual([PAID.id, PAID.id])
|
||
})
|
||
|
||
test('a switch action moves the picker to the model it names', async ({ page }) => {
|
||
await limits(page, OK)
|
||
await refuse(page, 402, { code: 'model_cap', message: 'Zen 6 is paused for now.', class: 'ours', actions: [{ kind: 'switch', label: 'Try Enso', model: AUTO.id }] })
|
||
await arrive(page, base, 'pro', PAID.id)
|
||
await send(page, 'hello')
|
||
await banner(page).getByRole('button', { name: 'Try Enso' }).click()
|
||
await expect.poll(() => kept(page)).toBe(AUTO.id)
|
||
})
|
||
|
||
test('a paused class: its models say Paused and still pick, with no banner before a send', async ({ page }) => {
|
||
await limits(page, OURS)
|
||
await arrive(page, base, 'pro', AUTO.id)
|
||
await chip(page).click()
|
||
const paused = row(page, PAID.id)
|
||
await expect(paused).toContainText('Paused', { timeout: 20_000 })
|
||
await expect(paused).toHaveAttribute('data-paused', 'true')
|
||
await expect(banner(page)).toHaveCount(0)
|
||
await paused.click()
|
||
await expect.poll(() => kept(page)).toBe(PAID.id)
|
||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||
})
|
||
|
||
test('the picker offers every model, Free or paid, each chosen with no ask', async ({ page }) => {
|
||
await limits(page, OK)
|
||
await arrive(page, base, 'free', AUTO.id, [THIRD])
|
||
await chip(page).click()
|
||
await expect(row(page, PAID.id)).toBeVisible()
|
||
await expect(row(page, THIRD.id)).toBeVisible()
|
||
await row(page, PAID.id).click()
|
||
await expect.poll(() => kept(page)).toBe(PAID.id)
|
||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||
})
|
||
|
||
test('a premium pick holds for the conversation and is never remembered as the default', async ({ page }) => {
|
||
await limits(page, FREE)
|
||
await arrive(page, base, 'free', FREE_LANE.id, [THIRD])
|
||
await chip(page).click()
|
||
await row(page, THIRD.id).click()
|
||
await expect(chip(page)).toContainText(THIRD.name)
|
||
expect(await kept(page)).toBe(FREE_LANE.id)
|
||
})
|
||
|
||
test('a premium model kept from before is not a default: the room answers on Enso', async ({ page }) => {
|
||
const heard = await limits(page, OK)
|
||
const asked: string[] = []
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/chat/completions',
|
||
(route) => {
|
||
asked.push((JSON.parse(route.request().postData() ?? '{}') as { model?: string }).model ?? '')
|
||
return route.fulfill({ status: 200, contentType: 'text/event-stream', headers: { 'Access-Control-Expose-Headers': EXPOSE }, body: SSE })
|
||
},
|
||
)
|
||
await arrive(page, base, 'pro', THIRD.id, [THIRD])
|
||
await expect(chip(page)).toContainText('Enso')
|
||
await send(page, 'hello')
|
||
await expect(page.getByText('Done.', { exact: true })).toBeVisible({ timeout: 20_000 })
|
||
expect(asked).toEqual([AUTO.id])
|
||
expect(heard.reads).toBeGreaterThan(0)
|
||
})
|
||
|
||
for (const [status, code, message, retry] of [
|
||
[402, 'plan_allowance_used', "You've used this period's included premium usage.", undefined],
|
||
[402, 'paid_plan_required', 'Premium models need a paid plan.', undefined],
|
||
[429, 'free_plan_cap', "You've reached the free plan's limit for now.", '3600'],
|
||
[402, 'insufficient_balance', 'Your prepaid credit has run out.', undefined],
|
||
] as const) {
|
||
test(`a ${status} ${code} is said in the thread and leads to the banner`, async ({ page }, info) => {
|
||
await limits(page, code === 'free_plan_cap' ? FREE : OK)
|
||
await refuse(page, status, { code, message, class: code === 'free_plan_cap' ? 'free' : 'premium', resets_at: PERIOD_END }, retry)
|
||
await arrive(page, base, code === 'free_plan_cap' ? 'free' : 'pro', AUTO.id)
|
||
await send(page, 'hello')
|
||
const failed = page.locator('[data-slot="failure"]')
|
||
const said = code === 'plan_allowance_used' ? 'Your Max 5x plan’s included premium model usage is used until Oct 30.' : message
|
||
await expect(failed).toContainText(said, { timeout: 20_000 })
|
||
await expect(banner(page)).toContainText(said)
|
||
for (const name of ['Add credits', 'Upgrade', 'See usage']) await expect(banner(page).getByRole('button', { name })).toBeVisible()
|
||
if (code === 'free_plan_cap') await expect(failed.getByRole('button', { name: /try again/i })).toHaveCount(0)
|
||
expect(await figures(page)).toEqual([])
|
||
await page.screenshot({ path: info.outputPath(`chat-refused-${code}.png`) })
|
||
})
|
||
}
|
||
})
|
||
|
||
/** Sam, an owner in his own organization, every other call answered empty. */
|
||
async function owner(page: Page, body: unknown) {
|
||
await page.route('**/v1/**', (route) => route.fulfill({ json: {} }))
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/iam/organizations/admin/sam',
|
||
(route) => route.fulfill({ json: { owner: 'admin', name: 'sam', displayName: 'Sam', isPersonal: true } }),
|
||
)
|
||
await signIn(page, { sub: 'u-sam', name: 'sam', email: '[email protected]', orgs: [{ org: 'sam', role: 'owner' }] })
|
||
await answer(page, '/v1/billing/tier', {
|
||
user: 'u-sam',
|
||
plan: 'max-5x',
|
||
tier: { name: 'pro', displayName: 'Pro', dailyCreditsCents: 500, maxAgents: 10, unlimitedAgents: false, allowedModels: ['*'] },
|
||
balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 },
|
||
windows: [{ span: 'minute', limit: 60, used: 1, remaining: 59, resets: PERIOD_END }],
|
||
})
|
||
await answer(page, '/v1/billing/balance', { balance: 0, holds: 0, available: 0, account: 'sam' })
|
||
// The plan authority, as the gates answer it: the plan's name is read from it.
|
||
await answer(page, '/v1/billing/plans', PLANS)
|
||
return limits(page, body)
|
||
}
|
||
|
||
test.describe('settings state the plan usage in plain words', () => {
|
||
test.use({ timezoneId: 'UTC' })
|
||
let base: string
|
||
let stop: () => Promise<void>
|
||
test.beforeAll(async () => {
|
||
const served = await serveExport()
|
||
base = served.url
|
||
stop = served.close
|
||
})
|
||
test.afterAll(async () => stop())
|
||
|
||
for (const scheme of ['dark', 'light'] as const)
|
||
for (const size of [
|
||
{ width: 1440, height: 900 },
|
||
{ width: 390, height: 844 },
|
||
]) {
|
||
test(`usage at ${size.width}px, ${scheme}: plan, period, meters, terms and actions`, async ({ page }, info) => {
|
||
await page.setViewportSize(size)
|
||
await theme(page, scheme)
|
||
await owner(page, { ...OK, actions: ACTIONS })
|
||
await page.goto(`${base}/settings/organization/usage`)
|
||
const section = page.getByRole('region', { name: 'Plan usage' })
|
||
await expect(section).toBeVisible({ timeout: 30_000 })
|
||
for (const said of [
|
||
'Max 5x plan',
|
||
'Current period Sep 30 – Oct 30',
|
||
'Premium models',
|
||
'35% used',
|
||
'Hanzo models',
|
||
'20% used',
|
||
'Resets Oct 30',
|
||
'Your plan includes usage of premium and Hanzo models each period.',
|
||
'Beyond it, prepaid credit pays when you choose to continue with credits.',
|
||
'Otherwise you continue in limited mode on free models.',
|
||
])
|
||
await expect(section).toContainText(said)
|
||
await expect(section.getByRole('progressbar', { name: 'Premium models: 35% used' })).toBeVisible()
|
||
await expect(section.getByRole('button', { name: 'Upgrade' })).toBeVisible()
|
||
await expect(section.getByRole('button', { name: 'Add prepaid credit' })).toBeVisible()
|
||
// The bar's colour is the state's; ok is the success rung.
|
||
const fill = await section
|
||
.locator('[data-slot="usage-meter-row"][data-class="premium"] [data-slot="progress-indicator"]')
|
||
.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||
expect(fill).toBe('rgb(34, 197, 94)')
|
||
// The plan's own name ("Max 5x") is the catalog's; every other line states no figure.
|
||
expect((await section.innerText()).replace(/Max \d+x plan/, '')).not.toMatch(FIGURE)
|
||
await expect(page.getByText(/requests left/i)).toHaveCount(0)
|
||
expect(await figures(page)).toEqual([])
|
||
expect(await wide(page), 'no sideways scroll').toBe(false)
|
||
await page.screenshot({ path: info.outputPath(`usage-${size.width}-${scheme}.png`), fullPage: true })
|
||
})
|
||
}
|
||
|
||
test('usage offers the credits choice and writes it', async ({ page }, info) => {
|
||
await owner(page, { ...OK, credits_after_allowance: false, classes: { premium: cls(100, 'ok', 'credits'), ours: cls(20, 'ok') } })
|
||
const wrote: unknown[] = []
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/ai/limits',
|
||
(route) => {
|
||
if (route.request().method() !== 'PUT') return route.fallback()
|
||
wrote.push(route.request().postDataJSON())
|
||
return route.fulfill({ json: { ...OK, credits_after_allowance: true } })
|
||
},
|
||
)
|
||
await page.goto(`${base}/settings/organization/usage`)
|
||
const section = page.getByRole('region', { name: 'Plan usage' })
|
||
await expect(section).toContainText("When your plan's included usage runs out, keep using any model and pay from your credits.", { timeout: 30_000 })
|
||
await expect(section).toContainText('Paying from credits')
|
||
await expect(section.getByRole('link', { name: 'Add credits' })).toBeVisible()
|
||
await section.getByRole('switch', { name: 'Continue with credits' }).click()
|
||
await expect.poll(() => wrote).toEqual([{ creditsAfterAllowance: true }])
|
||
await page.screenshot({ path: info.outputPath('usage-credits.png'), fullPage: true })
|
||
})
|
||
|
||
test('usage leads with the pause when a class is limited', async ({ page }, info) => {
|
||
await owner(page, PREMIUM)
|
||
await page.goto(`${base}/settings/organization/usage`)
|
||
const section = page.getByRole('region', { name: 'Plan usage' })
|
||
await expect(section.locator('[data-slot="limited-banner"]')).toContainText('Premium models are paused until Oct 30.', { timeout: 30_000 })
|
||
await expect(section.locator('[data-slot="usage-meter-row"][data-class="premium"]')).toHaveAttribute('data-state', 'limited')
|
||
await expect(section).toContainText('Paused')
|
||
const fill = await section
|
||
.locator('[data-slot="usage-meter-row"][data-class="premium"] [data-slot="progress-indicator"]')
|
||
.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||
expect(fill).toBe('rgb(239, 68, 68)')
|
||
await page.screenshot({ path: info.outputPath('usage-limited-1280-dark.png'), fullPage: true })
|
||
})
|
||
|
||
test('usage on Free says what Free includes and keeps the Spend panel', async ({ page }) => {
|
||
await owner(page, FREE)
|
||
await page.goto(`${base}/settings/organization/usage`)
|
||
const section = page.getByRole('region', { name: 'Plan usage' })
|
||
await expect(section).toContainText('Free plan', { timeout: 30_000 })
|
||
await expect(section).toContainText('The free plan includes limited usage of free models.')
|
||
await expect(page.getByRole('region', { name: 'Spend' })).toBeVisible()
|
||
})
|
||
})
|