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:
Jamie Pine
2026-08-08 15:40:36 -07:00
parent c7a3ff5d65
commit 37b7110e52
12 changed files with 379 additions and 0 deletions
BIN
View File
Binary file not shown.
+8
View File
@@ -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]
+92
View File
@@ -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
+25
View File
@@ -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
+3
View File
@@ -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=="],
+99
View File
@@ -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';
+58
View File
@@ -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 };
}
+36
View File
@@ -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,
},
});
+31
View File
@@ -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');
});
+16
View File
@@ -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();
});
+10
View File
@@ -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();
});
+1
View File
@@ -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",