mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
560 lines
28 KiB
TypeScript
560 lines
28 KiB
TypeScript
// 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()
|
||
}
|
||
})
|