Files
hanzo.ai/e2e/voice-live.spec.ts

560 lines
28 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.
// Voice, end to end, against the real gateway.
//
// The microphone hears a sentence Hanzo's own voice spoke (/v1/audio/speech), the
// composer fills through /v1/audio/transcriptions, the sentence is sent, and Listen
// reads the answer back through /v1/audio/speech. Chromium's fake capture device
// plays the WAV, so the browser's own recogniser plays no part: this is the path
// every browser takes, including Chromium on Linux and Firefox, which have none.
//
// hanzo auth token > /tmp/hz.tok
// BASE_URL=http://localhost:3000 pnpm exec playwright test --project=chromium e2e/voice-live.spec.ts
// BASE_URL=https://hanzo.ai pnpm exec playwright test --project=chromium e2e/voice-live.spec.ts
import { test, expect, chromium, type Page, type Response } from '@playwright/test'
import { writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { BASE, api, field, sendButton, settled, shot, ready } from './app'
import { credential, signedIn } from './credential'
const SAID = 'What is the capital of France?'
/**
* A minute and more of speech, a city to a sentence, so the transcript can be read
* back for every part of it: the first city proves the start was heard, the last
* that nothing past the gateway's one-minute rate window was dropped.
*/
const LONG = [
'In Paris, the first train of the morning leaves before the sun comes up over the river.',
'By noon the same carriages are rolling slowly through the green hills outside Vienna.',
'A friend of mine once spent a whole summer teaching music to children in Nairobi.',
'She wrote to me every week about the markets, the rain, and the long bus rides.',
'Later she moved to Lima, where the fog sits on the coast for half of the year.',
'Her brother works on fishing boats in the cold dark water just north of Oslo.',
'He says the winter nights are long, but the northern lights make up for all of it.',
'Their parents still live in Cairo, in a flat with a balcony above a busy street.',
'Every spring the whole family meets for a week in a small hotel in Lisbon.',
'Last year a cousin flew in from Sydney and brought a box of strange sweets.',
'Another cousin drove all the way from Toronto with two dogs in the back seat.',
'They argued about football in Madrid and about coffee in a tiny cafe in Berlin.',
'Next year they plan to rent a house by the sea somewhere south of Dublin.',
'If that falls through, the backup plan is a long trip by train across Seoul.',
'Whatever happens, the last postcard will be mailed from Chicago in the autumn.',
]
const CITIES = ['Paris', 'Vienna', 'Nairobi', 'Lima', 'Oslo', 'Cairo', 'Lisbon', 'Sydney', 'Toronto', 'Madrid', 'Berlin', 'Dublin', 'Seoul', 'Chicago']
/** The pcm16 body of a WAV, and the header that describes it. */
function split(wav: Buffer): { head: Buffer; pcm: Buffer; rate: number; block: number } {
const rate = wav.readUInt32LE(24)
const block = wav.readUInt16LE(32)
let at = 12
while (at < wav.length && wav.toString('ascii', at, at + 4) !== 'data') at += 8 + wav.readUInt32LE(at + 4)
return { head: Buffer.from(wav.subarray(0, at + 8)), pcm: wav.subarray(at + 8), rate, block }
}
/**
* One WAV of every clip in order, with quiet on either side of the speech. The fake
* device loops the file, and the recorder cuts a turn on a pause, so the trailing
* quiet is what ends the utterance before the sentence plays again. The clips are
* the same voice and format, so their samples join as they are.
*/
function padded(wavs: Buffer | Buffer[], lead: number, tail: number): Buffer {
const parts = (Array.isArray(wavs) ? wavs : [wavs]).map(split)
const { head, rate, block } = parts[0]!
const quiet = (s: number) => Buffer.alloc(Math.round(s * rate) * block)
const body = Buffer.concat([quiet(lead), ...parts.map((p) => p.pcm), quiet(tail)])
head.writeUInt32LE(head.length - 8 + body.length, 4)
head.writeUInt32LE(body.length, head.length - 4)
return Buffer.concat([head, body])
}
/** Seconds of audio in a WAV. */
const seconds = (wav: Buffer) => {
const { pcm, rate, block } = split(wav)
return pcm.length / block / rate
}
test.describe.configure({ timeout: 300_000 })
const played = (page: Page) =>
page.evaluate(() => (window as unknown as { __played: string[] }).__played)
/**
* A signed-in page in a Chromium whose microphone plays SAID, spoken by Hanzo.
*
* Fetching the sentence is also the speech probe: a 200 with WAV bytes, or the
* spec stops here naming what the gateway said.
*/
async function spoken(token: string, input: string): Promise<Buffer> {
// An org runs four speech requests at once, and parallel specs are one org: a
// fixture turned away for that waits its turn rather than failing the case.
const until = Date.now() + 120_000
let answer: globalThis.Response
let audio: Buffer
for (;;) {
answer = await fetch(`${api()}/v1/audio/speech`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'zen-voice-mini', voice: 'af_heart', response_format: 'wav', input }),
})
audio = Buffer.from(await answer.arrayBuffer())
if (answer.status !== 429 || !audio.toString().includes('speech_org_limit') || Date.now() > until) break
await new Promise((resume) => setTimeout(resume, 3_000))
}
expect(answer.status, `speech answered ${answer.status}: ${audio.toString().slice(0, 200)}`).toBe(200)
expect(answer.headers.get('content-type')).toMatch(/^audio\/(wav|x-wav|wave)/)
expect(audio.toString('ascii', 0, 4)).toBe('RIFF')
return audio
}
async function hearing(
token: string,
{ visitor = false, said = [SAID], tail = 2.5 }: { visitor?: boolean; said?: string[]; tail?: number } = {},
): Promise<{ page: Page; close: () => Promise<void>; length: number }> {
const clips: Buffer[] = []
for (const line of said) clips.push(await spoken(token, line))
const audio = padded(clips, 0.6, tail)
const wav = join(mkdtempSync(join(tmpdir(), 'voice-')), 'said.wav')
writeFileSync(wav, audio)
const browser = await chromium.launch({
args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
`--use-file-for-fake-audio-capture=${wav}`,
'--autoplay-policy=no-user-gesture-required',
],
})
const context = await browser.newContext({ permissions: ['microphone', 'camera'], viewport: { width: 1440, height: 950 } })
// The token makes the sentence; a visitor's page never holds it.
if (!visitor) await context.addInitScript(signedIn(token), token)
// Playback, observed where it happens. The voice plays a detached `new Audio()`,
// whose events never reach the document, so each element is watched as it is
// asked to play and counted once it actually is playing.
await context.addInitScript(() => {
const w = window as unknown as { __played: string[] }
w.__played = []
const play = HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function (this: HTMLMediaElement) {
this.addEventListener('playing', () => w.__played.push(this.currentSrc), { once: true })
return play.call(this)
}
// Talk mode plays through the audio graph, a buffer source per delta.
const start = AudioBufferSourceNode.prototype.start
AudioBufferSourceNode.prototype.start = function (this: AudioBufferSourceNode, ...args: Parameters<AudioBufferSourceNode['start']>) {
w.__played.push(`buffer:${this.buffer?.duration.toFixed(2)}s`)
return start.apply(this, args)
}
})
return { page: await context.newPage(), close: () => browser.close(), length: seconds(audio) }
}
const posted = (path: string) => (r: Response) => r.url().endsWith(path) && r.request().method() === 'POST'
/** The home page's composer: the field, and the mic beside it (found by its state,
* since its name changes as it listens). */
const home = (page: Page, label = 'Ask Hanzo anything') => {
const form = page.locator('form', { has: page.getByLabel(label) }).first()
return { draft: form.getByLabel(label), mic: form.locator('button[data-state]') }
}
for (const [where, path, label] of [
['the home page', '/', 'Ask Hanzo anything'],
['the start page', '/start', 'Ask anything or describe what to build'],
] as const)
test(`a visitor with no account dictates through Hanzo on ${where}`, async () => {
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok (it makes the sentence the mic hears)')
const { page, close } = await hearing(token!, { visitor: true })
try {
const heard = page.waitForResponse(posted('/v1/audio/transcriptions/public'), { timeout: 60_000 })
await page.goto(`${BASE}${path}`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
const { draft, mic } = home(page, label)
await mic.scrollIntoViewIfNeeded()
await expect(mic, 'the home composer must offer the mic').toBeVisible({ timeout: 30_000 })
await mic.click()
const transcribed = await heard
const body = await transcribed.text()
expect(transcribed.request().headers()['authorization'], 'a visitor sends no credential').toBeUndefined()
expect(transcribed.status(), `public transcription answered ${transcribed.status()}: ${body.slice(0, 200)}`).toBe(200)
expect(JSON.parse(body).text).toMatch(/capital of France/i)
console.log(`visitor heard: ${body}`)
await expect(draft).toHaveValue(/capital of France/i, { timeout: 30_000 })
await shot(page, `voice-visitor-dictated${path.replace(/\//g, '-')}`)
await mic.click()
} finally {
await close()
}
})
test('a visitor whose free day is spent keeps the mic, on the browser, and is told so', async () => {
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok (it makes the sentence the mic hears)')
const { page, close } = await hearing(token!, { visitor: true })
try {
// The refusal the lane gives on the fifty-first dictation of a day.
await page.route('**/v1/audio/transcriptions/public', (route) =>
route.fulfill({
status: 429,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': '*' },
body: JSON.stringify({ error: { message: "You've used today's free dictation.", type: 'insufficient_quota', code: 'public_allowance_spent' } }),
}),
)
await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
const { mic } = home(page)
await mic.scrollIntoViewIfNeeded()
await mic.click()
await expect(mic).toHaveAttribute('aria-label', /Today's free Hanzo dictation is used/, { timeout: 60_000 })
await expect(mic).toHaveAttribute('data-refusal', 'ear')
await shot(page, 'voice-visitor-spent')
await mic.click()
} finally {
await close()
}
})
test('the mic dictates through Hanzo, and Listen reads the answer through Hanzo', async () => {
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok')
const { page, close } = await hearing(token!)
try {
const heard = page.waitForResponse(posted('/v1/audio/transcriptions'), { timeout: 60_000 })
await page.goto(`${BASE}/chat`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
// DICTATION: press the mic and the composer fills with what was said.
const mic = page.getByRole('button', { name: /^Dictate/ }).first()
await expect(mic, 'the composer must offer the mic').toBeVisible({ timeout: 30_000 })
await mic.click()
const transcribed = await heard
const body = await transcribed.text()
expect(transcribed.status(), `transcription answered ${transcribed.status()}: ${body.slice(0, 200)}`).toBe(200)
expect(JSON.parse(body).text).toMatch(/capital of France/i)
console.log(`heard: ${body}`)
const draft = field(page)
await expect(draft).toHaveValue(/capital of France/i, { timeout: 30_000 })
await shot(page, 'voice-dictated')
await page.getByRole('button', { name: /^Dictating/ }).first().click()
// SEND IT, and wait for the answer.
const send = sendButton(page)
await expect.poll(() => ready(send), { timeout: 15_000 }).toBe(true)
await send.click()
const reply = page.locator('[data-slot="message"][data-role="assistant"]').last()
await expect(reply).toContainText(/\w{3,}/, { timeout: 120_000 })
// The whole answer, not its first words: the stream is over when Stop is Send again.
await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0, { timeout: 120_000 })
console.log(`answered: ${(await reply.innerText()).split('\n')[0]}`)
// LISTEN: the answer is read through /v1/audio/speech and playback starts.
const listen = reply.getByRole('button', { name: /^Listen/ }).first()
await expect(listen, 'the answer must offer Listen').toBeVisible({ timeout: 60_000 })
const read = page.waitForResponse(posted('/v1/audio/speech'), { timeout: 60_000 })
await listen.click()
const voiced = await read
expect(voiced.status(), `speech answered ${voiced.status()}`).toBe(200)
expect(voiced.headers()['content-type']).toMatch(/^audio\//)
// What arrived on the wire. body() can read back empty for a response the page
// consumed as a blob; the transfer size is what the browser actually received.
const { responseBodySize } = await voiced.request().sizes()
expect(responseBodySize, 'speech answered with no audio').toBeGreaterThan(1000)
console.log(`spoke: ${voiced.status()} ${voiced.headers()['content-type']} ${responseBodySize} bytes, voice ${JSON.parse(voiced.request().postData() ?? '{}').voice}`)
await expect
.poll(async () => (await played(page)).length, { timeout: 20_000, message: 'the answer was fetched and never played' })
.toBeGreaterThan(0)
console.log(`played: ${(await played(page)).join(', ')}`)
await shot(page, 'voice-listening')
} finally {
await close()
}
})
// TALK MODE: Meet's hands-free is one spoken conversation with the co-pilot. The
// utterance lands in the call's notes, the co-pilot answers it, and the answer is
// read aloud through /v1/audio/speech — no SpeechRecognition anywhere.
test('Meet hands-free hears through Hanzo, answers, and speaks the answer', async () => {
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok')
const { page, close } = await hearing(token!)
try {
await page.goto(`${BASE}/meet`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
await page.getByRole('button', { name: /Start Instant Meeting/ }).first().click()
const heard = page.waitForResponse(posted('/v1/audio/transcriptions'), { timeout: 60_000 })
const toggle = page.getByTestId('hands-free-voice-toggle')
await expect(toggle, 'the call must offer hands-free').toBeVisible({ timeout: 30_000 })
const spoken = page.waitForResponse(posted('/v1/audio/speech'), { timeout: 120_000 })
await toggle.click()
const transcribed = await heard
const body = await transcribed.text()
expect(transcribed.status(), `transcription answered ${transcribed.status()}: ${body.slice(0, 200)}`).toBe(200)
console.log(`meet heard: ${body}`)
await expect(page.getByText(/You \(voice\): .*capital of France/i).first()).toBeVisible({ timeout: 30_000 })
const answer = page.getByText(/\(AI\): .{3,}/).first()
await expect(answer, 'the co-pilot must answer what it heard').toBeVisible({ timeout: 120_000 })
console.log(`meet answered: ${await answer.innerText()}`)
const voiced = await spoken
expect(voiced.status(), `speech answered ${voiced.status()}`).toBe(200)
expect(voiced.headers()['content-type']).toMatch(/^audio\//)
console.log(`meet spoke: ${voiced.status()} ${voiced.headers()['content-type']}, voice ${JSON.parse(voiced.request().postData() ?? '{}').voice}`)
await expect
.poll(async () => (await played(page)).length, { timeout: 20_000, message: 'the answer was fetched and never played' })
.toBeGreaterThan(0)
await shot(page, 'voice-meet')
await toggle.click()
} finally {
await close()
}
})
// TALK MODE: the composer's talk button opens Hanzo's realtime socket, /v1/voice.
// The fake mic asks the question, the answer comes back as speech over the
// socket with its words drawn above the composer, and it plays.
test('Talk mode converses on /v1/voice and plays the answer', async () => {
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok')
// The fake device loops its file, and talk mode is full duplex: the question
// starting again is the speaker talking over the answer, which ends the answer
// before its audio is made. A person asks and waits, so the question is followed
// by the quiet of waiting — long enough for the answer to be thought and spoken.
const { page, close } = await hearing(token!, { tail: 15 })
try {
const frames: string[] = []
page.on('websocket', (ws) => {
if (!ws.url().includes('/v1/voice')) return
ws.on('framereceived', (f) => {
const frame = JSON.parse(String(f.payload)) as { type?: string; error?: { message?: string } }
if (frame.type) frames.push(frame.type === 'error' ? `error(${frame.error?.message})` : frame.type)
})
})
await page.goto(`${BASE}/chat`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
const session = page.waitForResponse(posted('/v1/voice/session'), { timeout: 60_000 })
const talk = page.locator('[data-slot="composer-talk"]').first()
await expect(talk, 'the composer must offer talk mode').toBeVisible({ timeout: 30_000 })
await talk.click()
const opened = await session
expect(opened.status(), `session answered ${opened.status()}: ${(await opened.text()).slice(0, 200)}`).toBe(200)
const reply = page.locator('[data-slot="composer-talk-reply"]')
await expect(reply)
.toContainText(/Hanzo: .*Paris/i, { timeout: 120_000 })
.catch((e: Error) => {
throw new Error(`${e.message}\nframes: ${frames.join(', ')}`)
})
console.log(`talk replied: ${await reply.innerText()}`)
await expect
.poll(() => frames.includes('response.audio.delta'), { timeout: 30_000 })
.toBe(true)
.catch((e: Error) => {
throw new Error(`${e.message}\nframes: ${frames.join(', ')}`)
})
await expect
.poll(async () => (await played(page)).some((p) => p.startsWith('buffer:')), { timeout: 30_000, message: 'the spoken answer never played' })
.toBe(true)
console.log(`talk frames: ${[...new Set(frames)].join(', ')}`)
await shot(page, 'voice-talk')
await talk.click()
} finally {
await close()
}
})
// MEET'S LIVE TRANSCRIPT: Transcribe streams the mic into Hanzo's growing
// transcript, /v1/audio/transcript; what settles lands in the call's notes.
test('Meet transcribes live on /v1/audio/transcript', async () => {
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok')
const { page, close } = await hearing(token!)
try {
await page.goto(`${BASE}/meet`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
await page.getByRole('button', { name: /Start Instant Meeting/ }).first().click()
const toggle = page.getByTestId('live-transcript-toggle')
await expect(toggle, 'the call must offer a live transcript').toBeVisible({ timeout: 30_000 })
const opened = page.waitForResponse(posted('/v1/audio/transcript'), { timeout: 60_000 })
await toggle.click()
const open = await opened
expect(open.status(), `transcript answered ${open.status()}: ${(await open.text()).slice(0, 200)}`).toBe(201)
// The words while they are still being decoded, under the notes...
const caption = page.getByTestId('live-caption')
await expect(caption).toContainText(/capital of France/i, { timeout: 90_000 })
console.log(`meet caption: ${await caption.innerText()}`)
await shot(page, 'voice-meet-caption')
// ...and, settled, in the notes: stopping settles the tail.
await toggle.click()
const note = page.getByText(/You: .*capital of France/i).first()
await expect(note).toBeVisible({ timeout: 60_000 })
console.log(`meet transcribed: ${await note.innerText()}`)
await shot(page, 'voice-meet-transcript')
} finally {
await close()
}
})
// A MINUTE OF DICTATION: the live transcript pushes a quarter second at a time, so a
// minute is some 240 pushes — four times the gateway's sixty requests a minute. The
// session is admitted once, at open, and its pushes ride on that; a push the gateway
// does wait out is resent in order. Every city in the passage reaches the notes, and
// "unavailable" never appears.
test('Meet transcribes a minute of speech live, all of it, on /v1/audio/transcript', async () => {
test.setTimeout(480_000)
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok')
const { page, close, length } = await hearing(token!, { said: LONG })
expect(length, 'the passage must run past the one-minute rate window').toBeGreaterThan(60)
console.log(`long passage: ${length.toFixed(1)}s`)
const pushes = new Map<number, number>()
page.on('response', (r) => {
if (!/\/v1\/audio\/transcript\/[^/]+$/.test(r.url()) || r.request().method() !== 'POST') return
pushes.set(r.status(), (pushes.get(r.status()) ?? 0) + 1)
})
const tally = () => [...pushes].map(([status, n]) => `${status}×${n}`).join(' ')
try {
await page.goto(`${BASE}/meet`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
await page.getByRole('button', { name: /Start Instant Meeting/ }).first().click()
const toggle = page.getByTestId('live-transcript-toggle')
await expect(toggle, 'the call must offer a live transcript').toBeVisible({ timeout: 30_000 })
const opened = page.waitForResponse(posted('/v1/audio/transcript'), { timeout: 60_000 })
const started = Date.now()
await toggle.click()
const open = await opened
expect(open.status(), `transcript answered ${open.status()}: ${(await open.text()).slice(0, 200)}`).toBe(201)
// The last city is the end of the passage: wait for it in the words still being
// decoded, as long as the passage runs and a margin for the decoder.
const caption = page.getByTestId('live-caption')
await expect(caption)
.toContainText(/Chicago/i, { timeout: (length + 90) * 1000 })
.catch((e: Error) => {
throw new Error(`${e.message}\npushes: ${tally()}`)
})
console.log(`long caption after ${((Date.now() - started) / 1000).toFixed(0)}s, pushes ${tally()}`)
await expect(toggle).not.toHaveAttribute('data-refusal', /.+/)
await toggle.click()
// Settled, in the notes: every city, in the order it was said.
const notes = page.getByText(/You: /)
await expect(notes.filter({ hasText: /Chicago/i }).first()).toBeVisible({ timeout: 60_000 })
const written = (await notes.allInnerTexts()).join(' ')
console.log(`long notes: ${written}`)
const missing = CITIES.filter((city) => !new RegExp(city, 'i').test(written))
expect(missing, `the notes lost part of the passage: ${written}`).toEqual([])
const order = CITIES.map((city) => written.search(new RegExp(city, 'i')))
expect(order, 'the passage must land in the order it was said').toEqual([...order].sort((a, b) => a - b))
expect(await page.locator('body').innerText()).not.toMatch(/unavailable/i)
expect(pushes.get(200) ?? 0, `pushes: ${tally()}`).toBeGreaterThan(60)
console.log(`long pushes: ${tally()}`)
await shot(page, 'voice-meet-long')
} finally {
await close()
}
})
// A SESSION LOST MID-DICTATION carries on. A cloud deploy restarts the one
// replica that holds every transcript window, and the next push is answered 409:
// the id was minted by a process that is gone. The mic opens a new session, keeps
// what the lost one said, and sends it the push that went unanswered and all after
// it. The 409 is served in place of one real push, so the spec does not have to
// time a deploy.
test('a dictation whose session is lost mid-sentence carries on in a new one', async () => {
test.setTimeout(480_000)
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok')
const { page, close, length } = await hearing(token!, { said: LONG })
const opens: number[] = []
let seen = 0
try {
await page.route(/\/v1\/audio\/transcript(\/[^/]+)?$/, async (route) => {
const r = route.request()
if (r.method() === 'POST' && /\/transcript$/.test(r.url())) opens.push(seen)
// The 80th push goes to an id another instance minted, which is what a push
// reaches after a deploy: the gateway itself answers it 409.
if (r.method() === 'POST' && /\/transcript\/[^/]+$/.test(r.url()) && ++seen === 80)
return route.continue({ url: r.url().replace(/ats_[0-9a-f]+_/, 'ats_00000000_') })
return route.continue()
})
await page.goto(`${BASE}/meet`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
await page.getByRole('button', { name: /Start Instant Meeting/ }).first().click()
const toggle = page.getByTestId('live-transcript-toggle')
await expect(toggle, 'the call must offer a live transcript').toBeVisible({ timeout: 30_000 })
await toggle.click()
const caption = page.getByTestId('live-caption')
await expect(caption).toContainText(/Chicago/i, { timeout: (length + 90) * 1000 })
await expect(toggle).not.toHaveAttribute('data-refusal', /.+/)
await toggle.click()
const notes = page.getByText(/You: /)
await expect(notes.filter({ hasText: /Chicago/i }).first()).toBeVisible({ timeout: 60_000 })
const written = (await notes.allInnerTexts()).join(' ')
console.log(`lost session: opens after pushes ${opens.join(', ')}; notes: ${written}`)
expect(opens, 'a new session is opened once the first is lost').toEqual([0, 80])
const missing = CITIES.filter((city) => !new RegExp(city, 'i').test(written))
expect(missing.length, `the seam lost more than the words in flight: ${written}`).toBeLessThanOrEqual(1)
expect(await page.locator('body').innerText()).not.toMatch(/unavailable/i)
} finally {
await close()
}
})
// A SPENT WINDOW reads as the limit it is. The gateway's usage policy answers past
// it with 429 and the moment it lifts in Retry-After; the mic says the limit and
// when it resets, never that Hanzo is unavailable. The refusal is the gateway's own
// shape, served in its place, so the spec does not have to spend a real window.
test('a dictation limit reads as the limit and when it resets, never "unavailable"', async () => {
const token = credential()
test.skip(!token, 'no live IAM token at /tmp/hz.tok')
const { page, close } = await hearing(token!)
try {
const lifts = 20_440
const origin = new URL(BASE).origin
await page.route(/\/v1\/audio\/transcriptions$/, (route) =>
route.fulfill({
status: 429,
contentType: 'application/json',
headers: {
'Retry-After': String(lifts),
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Expose-Headers': 'Retry-After',
},
body: JSON.stringify({ error: { message: `Usage limit reached for this 8h. It resets in ${lifts} seconds.`, type: 'quota_error', code: 429 } }),
}),
)
await page.goto(`${BASE}/chat`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await settled(page)
const mic = page.getByRole('button', { name: /^Dictate/ }).first()
await expect(mic, 'the composer must offer the mic').toBeVisible({ timeout: 30_000 })
const asked = Date.now()
const refused = page.waitForResponse((r) => /\/v1\/audio\/transcriptions$/.test(r.url()) && r.status() === 429, { timeout: 60_000 })
await mic.click()
await refused
const stopped = page.locator('button[data-refusal="ear"]').first()
await expect(stopped).toHaveAttribute('aria-label', /Today's Hanzo dictation limit is reached\. It resets at \d{1,2}:\d{2}/, { timeout: 30_000 })
// The time it names is the one the gateway gave, give or take the answer's flight.
const said = (await stopped.getAttribute('aria-label')) ?? ''
const expected = [asked, Date.now()].map((t) =>
new Date(t + lifts * 1000).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
)
expect(expected.some((at) => said.includes(at)), `${said} should name ${expected.join(' or ')}`).toBe(true)
expect(said).not.toMatch(/unavailable/i)
console.log(`limit label: ${said}`)
await shot(page, 'voice-limit')
await stopped.click()
} finally {
await close()
}
})