Files
voicebox/e2e/fixtures.ts
Jamie Pine 37b7110e52 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.
2026-08-08 15:40:36 -07:00

100 lines
3.0 KiB
TypeScript

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';