mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 14:20:42 -07:00
test: Playwright E2E with real backend + fake TTS engine
e2e/ drives the web build (vite preview) against a per-worker uvicorn: each Playwright worker boots its own backend on its own port with a temp cwd, so SQLite and audio files are fully isolated and parallel- safe. The page fixture seeds the persisted voicebox-server store with the worker's URL before any script runs. VOICEBOX_FAKE_TTS=1 short-circuits get_tts_backend_for_engine to a backend that synthesizes a sine tone sized to the text — the real task queue, SSE progress, history rows, and audio serving all run, only inference is fake. backend/requirements-ci.txt is the slim dependency set validated to boot the app on a CPU-only runner. Specs: startup, settings layout, seeded profile in /voices, and generate-to-completed-audio through the full pipeline.
This commit is contained in:
BIN
Binary file not shown.
@@ -12,6 +12,7 @@ and a model config registry that eliminates per-engine dispatch maps.
|
||||
# HF_HUB_OFFLINE=1 and on network failures.
|
||||
from ..utils import hf_offline_patch # noqa: F401
|
||||
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, Optional, Tuple, List
|
||||
@@ -678,6 +679,13 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
|
||||
"""
|
||||
global _tts_backends
|
||||
|
||||
# Test mode: every engine resolves to the fake backend so the full
|
||||
# generation pipeline runs without model weights (see fake_backend.py).
|
||||
if os.environ.get("VOICEBOX_FAKE_TTS") == "1":
|
||||
from .fake_backend import get_fake_backend
|
||||
|
||||
return get_fake_backend()
|
||||
|
||||
# Fast path: check without lock
|
||||
if engine in _tts_backends:
|
||||
return _tts_backends[engine]
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Fake TTS backend for UI and E2E testing.
|
||||
|
||||
Activated by ``VOICEBOX_FAKE_TTS=1``. Every engine resolves to this backend,
|
||||
which synthesizes a quiet sine tone sized to the input text — so the full
|
||||
generation pipeline (task queue, SSE progress, database rows, audio serving)
|
||||
runs exactly as in production, minus model weights and GPU time.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_RATE = 24_000
|
||||
SECONDS_PER_CHAR = 0.02
|
||||
MIN_DURATION_S = 0.25
|
||||
TONE_HZ = 440.0
|
||||
AMPLITUDE = 0.1
|
||||
|
||||
|
||||
class FakeTTSBackend:
|
||||
"""Implements the TTSBackend protocol without any model."""
|
||||
|
||||
MODEL_CONFIGS: ClassVar[list] = []
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._loaded = False
|
||||
|
||||
async def load_model(self, model_size: str = "default") -> None:
|
||||
if self._loaded:
|
||||
return
|
||||
# Brief pause so the UI's loading_model state is observable.
|
||||
await asyncio.sleep(0.1)
|
||||
self._loaded = True
|
||||
logger.info("Fake TTS backend loaded (VOICEBOX_FAKE_TTS)")
|
||||
|
||||
async def load_model_async(self, model_size: str = "default") -> None:
|
||||
# Qwen engines are loaded through this variant (see load_engine_model).
|
||||
await self.load_model(model_size)
|
||||
|
||||
async def create_voice_prompt(
|
||||
self,
|
||||
audio_path: str,
|
||||
reference_text: str,
|
||||
use_cache: bool = True,
|
||||
) -> tuple[dict, bool]:
|
||||
return ({"fake": True, "audio_path": audio_path, "reference_text": reference_text}, False)
|
||||
|
||||
async def combine_voice_prompts(
|
||||
self,
|
||||
audio_paths: list[str],
|
||||
reference_texts: list[str],
|
||||
) -> tuple[np.ndarray, str]:
|
||||
combined_text = " ".join(reference_texts)
|
||||
return np.zeros(SAMPLE_RATE, dtype=np.float32), combined_text
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
text: str,
|
||||
voice_prompt: dict,
|
||||
language: str = "en",
|
||||
seed: Optional[int] = None,
|
||||
instruct: Optional[str] = None,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
duration_s = max(MIN_DURATION_S, len(text) * SECONDS_PER_CHAR)
|
||||
# Yield once so cancellation has a window, mirroring real inference.
|
||||
await asyncio.sleep(0.05)
|
||||
t = np.linspace(0.0, duration_s, int(SAMPLE_RATE * duration_s), endpoint=False)
|
||||
audio = (AMPLITUDE * np.sin(2.0 * np.pi * TONE_HZ * t)).astype(np.float32)
|
||||
return audio, SAMPLE_RATE
|
||||
|
||||
def unload_model(self) -> None:
|
||||
self._loaded = False
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
return self._loaded
|
||||
|
||||
def _get_model_path(self, model_size: str) -> str:
|
||||
return "fake"
|
||||
|
||||
|
||||
_fake_backend: Optional[FakeTTSBackend] = None
|
||||
|
||||
|
||||
def get_fake_backend() -> FakeTTSBackend:
|
||||
global _fake_backend
|
||||
if _fake_backend is None:
|
||||
_fake_backend = FakeTTSBackend()
|
||||
return _fake_backend
|
||||
@@ -0,0 +1,25 @@
|
||||
# Minimal dependency set to boot the backend on a CPU-only CI runner.
|
||||
# No TTS/STT model libraries — inference is covered by the fake TTS
|
||||
# backend (VOICEBOX_FAKE_TTS=1). Install CPU torch first on Linux:
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
# then: pip install -r backend/requirements-ci.txt
|
||||
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
pydantic>=2.5.0
|
||||
sqlalchemy>=2.0.0
|
||||
alembic>=1.13.0
|
||||
torch>=2.2.0
|
||||
huggingface_hub>=0.20.0
|
||||
numpy
|
||||
soundfile
|
||||
python-multipart
|
||||
sse-starlette
|
||||
psutil
|
||||
requests
|
||||
httpx
|
||||
fastmcp
|
||||
librosa
|
||||
pillow
|
||||
pydub
|
||||
pedalboard
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.12",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/browser": "^4.1.10",
|
||||
@@ -304,6 +305,8 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/[email protected]", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="],
|
||||
|
||||
"@playwright/test": ["@playwright/[email protected]", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
|
||||
|
||||
"@polka/url": ["@polka/[email protected]", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/[email protected]", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test as base } from '@playwright/test';
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const BASE_PORT = 18100;
|
||||
|
||||
export interface BackendFixture {
|
||||
url: string;
|
||||
dataDir: string;
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, timeoutMs = 60_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${url}/health`);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`backend at ${url} did not become healthy within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
export const test = base.extend<Record<never, never>, { backend: BackendFixture }>({
|
||||
backend: [
|
||||
async ({}, use, workerInfo) => {
|
||||
const port = BASE_PORT + workerInfo.workerIndex;
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
// uvicorn resolves the data dir from cwd, so a temp cwd isolates
|
||||
// each worker's SQLite and audio files completely.
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), `voicebox-e2e-${workerInfo.workerIndex}-`));
|
||||
const python = process.env.VOICEBOX_PYTHON ?? path.join(REPO_ROOT, '.venv-ci/bin/python');
|
||||
|
||||
const proc: ChildProcess = spawn(
|
||||
python,
|
||||
['-m', 'uvicorn', 'backend.main:app', '--port', String(port), '--log-level', 'warning'],
|
||||
{
|
||||
cwd: dataDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONPATH: REPO_ROOT,
|
||||
VOICEBOX_FAKE_TTS: '1',
|
||||
VOICEBOX_CORS_ORIGINS:
|
||||
'http://localhost:4173,http://127.0.0.1:4173,http://localhost:5173',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
const logs: Buffer[] = [];
|
||||
proc.stdout?.on('data', (chunk) => logs.push(chunk));
|
||||
proc.stderr?.on('data', (chunk) => logs.push(chunk));
|
||||
|
||||
try {
|
||||
await waitForHealth(url);
|
||||
} catch (error) {
|
||||
proc.kill('SIGKILL');
|
||||
throw new Error(`${(error as Error).message}\nbackend log:\n${Buffer.concat(logs)}`);
|
||||
}
|
||||
|
||||
await use({ url, dataDir });
|
||||
|
||||
proc.kill('SIGTERM');
|
||||
await new Promise((resolve) => {
|
||||
proc.once('exit', resolve);
|
||||
setTimeout(resolve, 5000);
|
||||
});
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
// Point the app's persisted server store at this worker's backend before
|
||||
// any page script runs.
|
||||
page: async ({ page, backend }, use) => {
|
||||
await page.addInitScript((serverUrl) => {
|
||||
window.localStorage.setItem(
|
||||
'voicebox-server',
|
||||
JSON.stringify({
|
||||
state: {
|
||||
serverUrl,
|
||||
isConnected: false,
|
||||
mode: 'local',
|
||||
keepServerRunningOnClose: false,
|
||||
customModelsDir: null,
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
}, backend.url);
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Direct REST seeding against the per-worker backend — faster and less
|
||||
* brittle than driving every prerequisite through the UI. */
|
||||
|
||||
export interface SeededProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** A 3-second 220 Hz sine WAV (backend requires samples >= 2s). */
|
||||
export function buildSampleWav(): Blob {
|
||||
const sampleRate = 24_000;
|
||||
const seconds = 3;
|
||||
const samples = sampleRate * seconds;
|
||||
const dataSize = samples * 2;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
const writeString = (offset: number, s: string) => {
|
||||
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
|
||||
};
|
||||
writeString(0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(8, 'WAVE');
|
||||
writeString(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(36, 'data');
|
||||
view.setUint32(40, dataSize, true);
|
||||
for (let i = 0; i < samples; i++) {
|
||||
view.setInt16(44 + i * 2, Math.round(0.1 * 32767 * Math.sin((2 * Math.PI * 220 * i) / sampleRate)), true);
|
||||
}
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
export async function seedProfile(backendUrl: string, name: string): Promise<SeededProfile> {
|
||||
const createRes = await fetch(`${backendUrl}/profiles`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, language: 'en' }),
|
||||
});
|
||||
if (!createRes.ok) throw new Error(`create profile failed: ${createRes.status}`);
|
||||
const profile = (await createRes.json()) as { id: string };
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', buildSampleWav(), 'sample.wav');
|
||||
form.append('reference_text', 'hello world this is a reference sample');
|
||||
const sampleRes = await fetch(`${backendUrl}/profiles/${profile.id}/samples`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!sampleRes.ok) throw new Error(`add sample failed: ${sampleRes.status} ${await sampleRes.text()}`);
|
||||
|
||||
return { id: profile.id, name };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E suite driving the web build (same app as Tauri, browser platform)
|
||||
* against a real CPU backend with the fake TTS engine. Each worker gets
|
||||
* its own uvicorn on its own port with its own data dir — see fixtures.ts.
|
||||
*
|
||||
* PW_DEV=1 targets `bun run dev:web` (port 5173) instead of the preview
|
||||
* build for faster local iteration.
|
||||
*/
|
||||
const DEV = !!process.env.PW_DEV;
|
||||
const PORT = DEV ? 5173 : 4173;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
|
||||
use: {
|
||||
baseURL: `http://localhost:${PORT}`,
|
||||
trace: 'on-first-retry',
|
||||
video: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
webServer: {
|
||||
command: DEV
|
||||
? 'bun run dev:web'
|
||||
: 'bun run build:web && cd web && bunx vite preview --port 4173 --strictPort',
|
||||
cwd: '..',
|
||||
port: PORT,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from '../fixtures';
|
||||
import { seedProfile } from '../helpers/api';
|
||||
|
||||
test('generate speech end to end through the fake TTS pipeline', async ({ page, backend }) => {
|
||||
const profile = await seedProfile(backend.url, 'Narrator');
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Select the seeded voice, type into the generate box, and submit.
|
||||
await page.getByText(profile.name).first().click();
|
||||
const input = page.getByRole('textbox').first();
|
||||
await input.click();
|
||||
await page.keyboard.type('The quick brown fox jumps over the lazy dog.');
|
||||
await page.getByRole('button', { name: 'Generate speech' }).click();
|
||||
|
||||
// The row lands in history and completes via the real queue + SSE.
|
||||
await expect(page.getByText('The quick brown fox', { exact: false }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await fetch(`${backend.url}/history?limit=10`);
|
||||
const body = (await res.json()) as { items: { status: string }[] };
|
||||
return body.items[0]?.status;
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toBe('completed');
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from '../fixtures';
|
||||
|
||||
test('app boots against the backend and shows the main editor', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Voicebox' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Create Voice' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('settings layout renders its pages', async ({ page }) => {
|
||||
await page.goto('/settings/about');
|
||||
|
||||
await expect(page.getByText('General', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Changelog', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('About', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from '../fixtures';
|
||||
import { seedProfile } from '../helpers/api';
|
||||
|
||||
test('a seeded voice profile appears in the voices tab', async ({ page, backend }) => {
|
||||
const profile = await seedProfile(backend.url, 'Marcus Aurelius');
|
||||
|
||||
await page.goto('/voices');
|
||||
|
||||
await expect(page.getByText(profile.name).first()).toBeVisible();
|
||||
});
|
||||
@@ -33,6 +33,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.12",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/browser": "^4.1.10",
|
||||
|
||||
Reference in New Issue
Block a user