Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
167 lines
11 KiB
JavaScript
167 lines
11 KiB
JavaScript
// Browser regressions with deterministic HTTP fixtures. Real-model validation
|
|
// is separate; these checks exercise UI state and recipe request construction.
|
|
import { createRequire } from 'node:module';
|
|
import path from 'node:path';
|
|
import assert from 'node:assert/strict';
|
|
|
|
const require = createRequire(process.env.UI_BROWSER_TOOLS
|
|
? path.resolve(process.env.UI_BROWSER_TOOLS, 'package.json') : import.meta.url);
|
|
const { chromium } = require('playwright');
|
|
const wav = Buffer.alloc(44 + 1600 * 2);
|
|
wav.write('RIFF'); wav.writeUInt32LE(wav.length - 8, 4); wav.write('WAVEfmt ', 8);
|
|
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
|
|
wav.writeUInt32LE(16000, 24); wav.writeUInt32LE(32000, 28);
|
|
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
|
|
wav.write('data', 36); wav.writeUInt32LE(wav.length - 44, 40);
|
|
const file = { name: 'speech.wav', mimeType: 'audio/wav', buffer: wav };
|
|
const packageIds = ['ming_3_0_tts_0_5b_q8_0', 'kitten_tts2_20261021_q4_0', 'canary_180m_flash_q8_0', 'moonshine_streaming_tiny_q8_0'];
|
|
const registered = new Map();
|
|
const speech = [];
|
|
const recipes = [];
|
|
let recipeBehavior = 'success';
|
|
let holdSpeech = false;
|
|
let releaseSpeech;
|
|
const browser = await chromium.launch({ channel: process.env.UI_BROWSER_CHANNEL || 'chrome', headless: true });
|
|
try {
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
const page = await context.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
await page.route('**/health', route => route.fulfill({ json: {
|
|
status: 'ok', backend: 'fixture', models: registered.size, ui: true, ui_management: true
|
|
} }));
|
|
await page.route('**/v1/**', async route => {
|
|
const req = route.request();
|
|
const url = new URL(req.url());
|
|
const data = req.method() === 'POST' && req.headers()['content-type']?.includes('application/json')
|
|
? req.postDataJSON() : {};
|
|
let json = { data: [], voices: [] };
|
|
if (url.pathname === '/v1/models') json = { data: [...registered.values()] };
|
|
if (url.pathname === '/v1/models/load') registered.set(data.id, { ...data, loaded: true, configured: false });
|
|
if (url.pathname === '/v1/models/unload') registered.delete(data.id);
|
|
if (url.pathname === '/v1/ui/models-root') json = { models_root: '/fixture', default_models_root: '/fixture', is_default: true };
|
|
if (url.pathname === '/v1/ui/models/package-sizes') json = { state: 'complete', message: '',
|
|
data: packageIds.map(id => ({ id, installed: true, size_bytes: 100, state: 'ok' })) };
|
|
if (url.pathname === '/v1/ui/path-status') json = { exists: true, file: true, directory: false };
|
|
if (url.pathname === '/v1/ui/upload') json = { path: '/fixture/upload.wav' };
|
|
if (url.pathname === '/v1/audio/speech') {
|
|
speech.push(data);
|
|
if (holdSpeech) await new Promise(resolve => { releaseSpeech = resolve; });
|
|
return route.fulfill({ status: 200, contentType: 'audio/wav', body: wav });
|
|
}
|
|
if (url.pathname === '/v1/recipes/run') {
|
|
recipes.push(data);
|
|
if (recipeBehavior === 'error') return route.fulfill({ status: 400, json: { error: { message: 'Translation failed' } } });
|
|
if (recipeBehavior === 'wait') await new Promise(resolve => setTimeout(resolve, 500));
|
|
json = { text: 'The train leaves in ten minutes.', audio: wav.toString('base64'),
|
|
timing: { wall_ms: 100, audio_duration_ms: 100, rtf: 1 } };
|
|
}
|
|
return route.fulfill({ json });
|
|
});
|
|
await page.goto(process.env.UI_BASE_URL || 'http://127.0.0.1:8080');
|
|
await page.getByRole('button', { name: /^Text to speech/ }).click();
|
|
await page.locator('#model').selectOption('ming3-tts');
|
|
await page.waitForFunction(() => document.querySelector('.runbar .status')?.textContent.trim() === 'Ready');
|
|
console.log('PASS selecting an installed model clears stale status');
|
|
const text = 'The library opens at nine in the morning. Please return the book before Friday. We will meet beside the old clock after lunch.';
|
|
await page.locator('#text').fill(text);
|
|
await page.getByRole('checkbox', { name: 'Split and merge long text' }).check();
|
|
await page.locator('#chunk-budget').fill('40');
|
|
await page.locator('button.run').click();
|
|
await page.waitForFunction(() => document.querySelector('.runbar .status')?.textContent.includes('Complete in'));
|
|
assert.equal(speech.length, 1);
|
|
assert.equal(speech[0].input, text);
|
|
assert.equal(speech[0].options.text_chunk_size, 40);
|
|
console.log('PASS Ming sends one complete request with native chunk settings');
|
|
speech.length = 0;
|
|
await page.locator('#model').selectOption('kitten-tts2-20261021');
|
|
await page.locator('#chunk-budget').fill('40');
|
|
await page.locator('#text').fill(text);
|
|
await page.locator('button.run').click();
|
|
await page.waitForFunction(() => document.querySelector('.runbar .status')?.textContent.includes('Complete in'));
|
|
assert(speech.length > 1);
|
|
assert(speech.every(request => request.model === 'kitten-tts2-20261021'));
|
|
console.log('PASS other models retain client-side splitting');
|
|
|
|
await page.getByRole('button', { name: 'Arena', exact: true }).click();
|
|
await page.locator('#arena-model').selectOption('kitten-tts2-20261021');
|
|
await page.locator('#arena-package').selectOption('kitten_tts2_20261021_q4_0');
|
|
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
|
await page.locator('#arena-text').fill('A short comparison.');
|
|
holdSpeech = true;
|
|
await page.getByRole('button', { name: 'Run arena', exact: true }).click();
|
|
await page.waitForFunction(() => document.querySelector('.arena-mode-switch button')?.disabled);
|
|
assert(await page.getByRole('button', { name: 'ASR', exact: true }).isDisabled());
|
|
for (let attempt = 0; attempt < 100 && !releaseSpeech; attempt++) {
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
assert(releaseSpeech, 'Arena must dispatch the held speech request');
|
|
holdSpeech = false;
|
|
releaseSpeech();
|
|
await page.getByRole('button', { name: 'ASR', exact: true }).waitFor();
|
|
await page.waitForFunction(() => !document.querySelector('.arena-mode-switch button')?.disabled);
|
|
assert.equal(await page.locator('.arena-grid audio').count(), 1);
|
|
await page.getByRole('button', { name: 'ASR', exact: true }).click();
|
|
assert.equal(await page.locator('.arena-grid audio').count(), 0);
|
|
assert(await page.getByRole('button', { name: 'Run arena', exact: true }).isDisabled());
|
|
console.log('PASS Arena locks task changes during a run and clears queue/results on task change');
|
|
|
|
await page.getByRole('button', { name: 'Apps', exact: true }).click();
|
|
await page.getByRole('button', { name: /Cross-language dubbing/ }).click();
|
|
const translator = page.getByLabel('Translation model', { exact: true });
|
|
await translator.locator('option[value="model:canary-asr:canary_180m_flash_q8_0"]').waitFor({ state: 'attached' });
|
|
assert.equal(await translator.locator('option').count(), 2, 'Only translation-capable ASR should be listed');
|
|
await translator.selectOption('model:canary-asr:canary_180m_flash_q8_0');
|
|
await page.getByLabel('Speech model', { exact: true }).selectOption('model:kitten-tts2-20261021:kitten_tts2_20261021_q4_0');
|
|
await page.getByLabel('Audio file', { exact: true }).setInputFiles(file);
|
|
await page.getByLabel('Source language', { exact: true }).fill('de');
|
|
await page.getByLabel('Target language', { exact: true }).fill('en');
|
|
await page.getByLabel('Dub voice', { exact: true }).selectOption('Bruno');
|
|
await page.getByRole('button', { name: 'Generate dub', exact: true }).click();
|
|
await page.getByText('Dub ready', { exact: true }).waitFor();
|
|
const recipe = recipes.at(-1);
|
|
assert.equal(recipe.recipe, 'cross-language-dubbing');
|
|
assert.deepEqual(recipe.models, { translator: 'canary-asr', tts: 'kitten-tts2-20261021' });
|
|
assert.equal(recipe.request.audio, '/fixture/upload.wav');
|
|
assert.equal(recipe.request.voice_id, 'Bruno');
|
|
assert.equal(recipe.request.options['translator.language'], 'de');
|
|
assert.equal(recipe.request.options['translator.target_language'], 'en');
|
|
assert(Number.isInteger(recipe.request.options['tts.seed']));
|
|
assert(!('language' in recipe.request.options));
|
|
const result = page.getByRole('region', { name: 'Dub result' });
|
|
const previousUrl = await result.locator('audio').getAttribute('src');
|
|
assert.equal(await result.getByRole('link', { name: 'WAV', exact: true }).getAttribute('download'), 'dub.wav');
|
|
assert.equal(await result.getByRole('link', { name: 'Translation', exact: true }).getAttribute('download'), 'translation.txt');
|
|
recipeBehavior = 'error';
|
|
await page.getByRole('button', { name: 'Generate dub', exact: true }).click();
|
|
await page.getByRole('alert').filter({ hasText: 'Translation failed' }).waitFor();
|
|
assert.equal(await result.locator('audio').getAttribute('src'), previousUrl);
|
|
recipeBehavior = 'wait';
|
|
await page.getByRole('button', { name: 'Generate dub', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
|
await page.getByText('Cancelled', { exact: true }).waitFor();
|
|
assert.equal(await result.locator('audio').getAttribute('src'), previousUrl);
|
|
console.log('PASS recipe routing, role options, voice, downloads, failure recovery and cancellation');
|
|
recipeBehavior = 'success';
|
|
await page.getByLabel('Speech model', { exact: true }).selectOption('model:ming3-tts:ming_3_0_tts_0_5b_q8_0');
|
|
await page.locator('.dubbing summary').filter({ hasText: 'Reference voice' }).click();
|
|
await page.getByLabel('Reference audio', { exact: true }).setInputFiles(file);
|
|
await page.getByLabel('Reference transcript', { exact: true }).fill('A reference sentence.');
|
|
await page.getByLabel('Source language', { exact: true }).fill('');
|
|
await page.locator('.dubbing .model-choice').first().getByRole('button', { name: 'More generation options', exact: true }).click();
|
|
await page.getByLabel('Translation model language', { exact: true }).fill('de');
|
|
await page.getByRole('button', { name: 'Generate dub', exact: true }).click();
|
|
await page.getByText('Dub ready', { exact: true }).waitFor();
|
|
assert.equal(recipes.at(-1).request.voice_ref, '/fixture/upload.wav');
|
|
assert.equal(recipes.at(-1).request.options['tts.reference_text'], 'A reference sentence.');
|
|
assert.equal(recipes.at(-1).request.options['translator.language'], 'de');
|
|
assert(!('voice_id' in recipes.at(-1).request));
|
|
assert(!('translator.reference_text' in recipes.at(-1).request.options));
|
|
console.log('PASS reference conditioning remains TTS-only and translator language settings are honored');
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
|
|
assert.deepEqual(errors, []);
|
|
console.log('PASS mobile width and no browser exceptions');
|
|
} finally {
|
|
await browser.close();
|
|
}
|