Files
hanzo.ai/e2e/gates/limits.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
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.
2026-10-22 19:07:20 -07:00

497 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})
})