diff --git a/app/src/test/harness.browser.test.tsx b/app/src/test/harness.browser.test.tsx new file mode 100644 index 00000000..77af25e1 --- /dev/null +++ b/app/src/test/harness.browser.test.tsx @@ -0,0 +1,58 @@ +import { http } from 'msw'; +import { expect, it } from 'vitest'; +import { buildModelStatus, buildProfile } from './msw/fixtures'; +import { + captureHandlers, + effectsHandlers, + historyHandlers, + modelHandlers, + profileHandlers, + settingsHandlers, + storyHandlers, + taskHandlers, +} from './msw/handlers'; +import { worker } from './msw/worker'; +import { renderRoute } from './render'; +import { sseController } from './sse'; + +function useHappyPathHandlers() { + worker.use( + ...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]), + ...historyHandlers([]), + ...captureHandlers([]), + ...settingsHandlers(), + ...modelHandlers([buildModelStatus()]), + ...storyHandlers([]), + ...effectsHandlers([]), + ...taskHandlers(), + ); +} + +it('renders the /voices route with the full app chrome', async () => { + useHappyPathHandlers(); + + const screen = await renderRoute('/voices'); + + await expect.element(screen.getByText('Ada Lovelace')).toBeVisible(); +}); + +it('feeds EventSource through the SSE controller', async () => { + const sse = sseController(); + worker.use(http.get('*/generate/:id/status', () => sse.response())); + + const source = new EventSource('/generate/gen-1/status'); + const statuses: string[] = []; + source.onmessage = (message) => { + statuses.push((JSON.parse(message.data) as { status: string }).status); + }; + await new Promise((resolve) => { + source.onopen = resolve; + }); + + sse.push({ data: { status: 'generating' } }); + sse.push({ data: { status: 'completed' } }); + + await expect.poll(() => statuses).toEqual(['generating', 'completed']); + source.close(); + sse.close(); +}); diff --git a/app/src/test/msw/fixtures.ts b/app/src/test/msw/fixtures.ts new file mode 100644 index 00000000..36bb15be --- /dev/null +++ b/app/src/test/msw/fixtures.ts @@ -0,0 +1,216 @@ +import type { + CaptureListResponse, + CaptureReadinessResponse, + CaptureResponse, + CaptureSettings, + EffectPresetResponse, + GenerationResponse, + GenerationSettings, + HealthResponse, + HistoryListResponse, + HistoryResponse, + ModelStatus, + StoryDetailResponse, + StoryItemDetail, + StoryResponse, + VoiceProfileResponse, +} from '@/lib/api/types'; + +// Deterministic id counter — no randomness so failures reproduce exactly. +let seq = 0; +export function nextId(prefix: string): string { + seq += 1; + return `${prefix}-${String(seq).padStart(4, '0')}`; +} + +const CREATED_AT = '2026-01-01T00:00:00Z'; + +export function buildProfile(overrides: Partial = {}): VoiceProfileResponse { + return { + id: nextId('profile'), + name: 'Test Voice', + language: 'en', + voice_type: 'cloned', + generation_count: 0, + sample_count: 1, + created_at: CREATED_AT, + updated_at: CREATED_AT, + ...overrides, + }; +} + +export function buildGeneration(overrides: Partial = {}): GenerationResponse { + return { + id: nextId('gen'), + profile_id: 'profile-0001', + text: 'Hello from the test suite.', + language: 'en', + status: 'completed', + audio_path: '/audio/fake.wav', + duration: 1.5, + created_at: CREATED_AT, + ...overrides, + }; +} + +export function buildHistoryItem(overrides: Partial = {}): HistoryResponse { + return { + ...buildGeneration(), + profile_name: 'Test Voice', + ...overrides, + }; +} + +export function buildHistoryList(items: HistoryResponse[]): HistoryListResponse { + return { items, total: items.length }; +} + +export function buildCapture(overrides: Partial = {}): CaptureResponse { + return { + id: nextId('capture'), + audio_path: '/captures/fake.wav', + source: 'dictation', + language: 'en', + duration_ms: 2400, + transcript_raw: 'raw transcript text', + transcript_refined: 'Refined transcript text.', + created_at: CREATED_AT, + ...overrides, + }; +} + +export function buildCaptureList(items: CaptureResponse[]): CaptureListResponse { + return { items, total: items.length }; +} + +export function buildCaptureSettings(overrides: Partial = {}): CaptureSettings { + return { + stt_model: 'turbo', + language: 'en', + auto_refine: true, + llm_model: '0.6B', + smart_cleanup: true, + self_correction: true, + preserve_technical: true, + allow_auto_paste: false, + default_playback_voice_id: null, + hotkey_enabled: false, + keep_mic_warm: false, + chord_push_to_talk_keys: [], + chord_toggle_to_talk_keys: [], + ...overrides, + }; +} + +export function buildCaptureReadiness( + overrides: Partial = {}, +): CaptureReadinessResponse { + return { + stt: { + ready: true, + model_name: 'whisper-turbo', + display_name: 'Whisper Turbo', + size: '1.6 GB', + }, + llm: { + ready: true, + model_name: 'qwen3-0.6b', + display_name: 'Qwen3 0.6B', + size: '600 MB', + }, + ...overrides, + }; +} + +export function buildGenerationSettings( + overrides: Partial = {}, +): GenerationSettings { + return { + max_chunk_chars: 400, + crossfade_ms: 60, + normalize_audio: true, + autoplay_on_generate: false, + ...overrides, + }; +} + +export function buildModelStatus(overrides: Partial = {}): ModelStatus { + return { + model_name: 'qwen-tts-1.7b', + display_name: 'Qwen TTS 1.7B', + downloaded: true, + downloading: false, + loaded: false, + size_mb: 3400, + ...overrides, + }; +} + +export function buildStory(overrides: Partial = {}): StoryResponse { + return { + id: nextId('story'), + name: 'Test Story', + created_at: CREATED_AT, + updated_at: CREATED_AT, + item_count: 0, + ...overrides, + }; +} + +export function buildStoryItem(overrides: Partial = {}): StoryItemDetail { + return { + id: nextId('story-item'), + story_id: 'story-0001', + generation_id: 'gen-0001', + start_time_ms: 0, + track: 0, + trim_start_ms: 0, + trim_end_ms: 0, + created_at: CREATED_AT, + profile_id: 'profile-0001', + profile_name: 'Test Voice', + text: 'Hello from the test suite.', + language: 'en', + audio_path: '/audio/fake.wav', + duration: 1.5, + volume: 1, + generation_created_at: CREATED_AT, + ...overrides, + }; +} + +export function buildStoryDetail( + overrides: Partial = {}, +): StoryDetailResponse { + return { + id: 'story-0001', + name: 'Test Story', + created_at: CREATED_AT, + updated_at: CREATED_AT, + items: [], + ...overrides, + }; +} + +export function buildEffectPreset( + overrides: Partial = {}, +): EffectPresetResponse { + return { + id: nextId('preset'), + name: 'Test Preset', + effects_chain: [{ type: 'reverb', enabled: true, params: { wet: 0.3 } }], + is_builtin: false, + created_at: CREATED_AT, + ...overrides, + }; +} + +export function buildHealth(overrides: Partial = {}): HealthResponse { + return { + status: 'ok', + model_loaded: false, + gpu_available: false, + backend_variant: 'cpu', + ...overrides, + }; +} diff --git a/app/src/test/msw/handlers/index.ts b/app/src/test/msw/handlers/index.ts new file mode 100644 index 00000000..79483384 --- /dev/null +++ b/app/src/test/msw/handlers/index.ts @@ -0,0 +1,102 @@ +import type { HttpHandler } from 'msw'; +import { HttpResponse, http } from 'msw'; +import type { + CaptureResponse, + CaptureSettings, + EffectPresetResponse, + GenerationSettings, + HistoryResponse, + ModelStatus, + StoryDetailResponse, + StoryResponse, + VoiceProfileResponse, +} from '@/lib/api/types'; +import { buildCaptureReadiness, buildCaptureSettings, buildGenerationSettings } from '../fixtures'; + +/** + * Happy-path handlers for one domain each. Tests compose what they need: + * worker.use(...profileHandlers([buildProfile()]), ...historyHandlers([])) + * Anything not stubbed fails loudly via onUnhandledRequest: 'error'. + */ + +export function profileHandlers(profiles: VoiceProfileResponse[]): HttpHandler[] { + return [ + http.get('*/profiles', () => HttpResponse.json(profiles)), + http.get('*/profiles/presets/:engine', () => HttpResponse.json([])), + http.get('*/profiles/:id', ({ params }) => { + const profile = profiles.find((p) => p.id === params.id); + return profile ? HttpResponse.json(profile) : new HttpResponse(null, { status: 404 }); + }), + http.get('*/profiles/:id/channels', () => HttpResponse.json([])), + http.get('*/profiles/:id/samples', () => HttpResponse.json([])), + http.get('*/channels', () => HttpResponse.json([])), + ]; +} + +export function historyHandlers(items: HistoryResponse[]): HttpHandler[] { + return [ + http.get('*/history', () => HttpResponse.json({ items, total: items.length })), + http.get('*/history/:id', ({ params }) => { + const item = items.find((i) => i.id === params.id); + return item ? HttpResponse.json(item) : new HttpResponse(null, { status: 404 }); + }), + ]; +} + +export function captureHandlers( + items: CaptureResponse[], + settings: CaptureSettings = buildCaptureSettings(), +): HttpHandler[] { + return [ + http.get('*/captures', () => HttpResponse.json({ items, total: items.length })), + http.get('*/capture/readiness', () => HttpResponse.json(buildCaptureReadiness())), + http.get('*/settings/captures', () => HttpResponse.json(settings)), + http.put('*/settings/captures', async ({ request }) => { + const update = (await request.json()) as Partial; + return HttpResponse.json({ ...settings, ...update }); + }), + ]; +} + +export function settingsHandlers( + generation: GenerationSettings = buildGenerationSettings(), +): HttpHandler[] { + return [ + http.get('*/settings/generation', () => HttpResponse.json(generation)), + http.put('*/settings/generation', async ({ request }) => { + const update = (await request.json()) as Partial; + return HttpResponse.json({ ...generation, ...update }); + }), + ]; +} + +export function modelHandlers(models: ModelStatus[]): HttpHandler[] { + return [ + http.get('*/models/status', () => HttpResponse.json({ models })), + http.get('*/models/cache-dir', () => HttpResponse.json({ cache_dir: '/tmp/models' })), + ]; +} + +export function storyHandlers( + stories: StoryResponse[], + details: StoryDetailResponse[] = [], +): HttpHandler[] { + return [ + http.get('*/stories', () => HttpResponse.json(stories)), + http.get('*/stories/:id', ({ params }) => { + const detail = details.find((d) => d.id === params.id); + return detail ? HttpResponse.json(detail) : new HttpResponse(null, { status: 404 }); + }), + ]; +} + +export function effectsHandlers(presets: EffectPresetResponse[]): HttpHandler[] { + return [ + http.get('*/effects/available', () => HttpResponse.json({ effects: [] })), + http.get('*/effects/presets', () => HttpResponse.json(presets)), + ]; +} + +export function taskHandlers(): HttpHandler[] { + return [http.get('*/tasks/active', () => HttpResponse.json({ downloads: [], generations: [] }))]; +} diff --git a/app/src/test/render.tsx b/app/src/test/render.tsx index f54c5b0d..b7b41ad4 100644 --- a/app/src/test/render.tsx +++ b/app/src/test/render.tsx @@ -1,7 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router'; import type { ReactNode } from 'react'; import { render } from 'vitest-browser-react'; import { PlatformProvider } from '@/platform/PlatformContext'; +import { routeTree } from '@/router'; import { createMockPlatform, type MockPlatform } from './mockPlatform'; export function createTestQueryClient(): QueryClient { @@ -25,9 +27,23 @@ export interface RenderWithProvidersOptions { queryClient?: QueryClient; } +// Every client handed to a render is drained on teardown so in-flight +// queries can't fire after MSW handlers reset (noisy unhandled-request +// errors between tests). +const activeQueryClients: QueryClient[] = []; + +export async function drainQueryClients(): Promise { + for (const client of activeQueryClients) { + await client.cancelQueries(); + client.clear(); + } + activeQueryClients.length = 0; +} + export async function renderWithProviders(ui: ReactNode, options: RenderWithProvidersOptions = {}) { const platform = options.platform ?? createMockPlatform(); const queryClient = options.queryClient ?? createTestQueryClient(); + activeQueryClients.push(queryClient); const result = await render( @@ -39,3 +55,17 @@ export async function renderWithProviders(ui: ReactNode, options: RenderWithProv // intact — spreading would drop them. return Object.assign(result, { platform, queryClient }); } + +/** + * Mount the real route tree at `route` over memory history — full app chrome + * (sidebar, frame, toasts) included. A throwaway router per call keeps route + * state from leaking between tests. + */ +export async function renderRoute(route: string, options: RenderWithProvidersOptions = {}) { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [route] }), + }); + const result = await renderWithProviders(, options); + return Object.assign(result, { router }); +} diff --git a/app/src/test/setup.browser.ts b/app/src/test/setup.browser.ts index cd83ad5f..e0b6d1d1 100644 --- a/app/src/test/setup.browser.ts +++ b/app/src/test/setup.browser.ts @@ -1,11 +1,18 @@ import { afterEach, beforeAll } from 'vitest'; +import { cleanup } from 'vitest-browser-react'; import { worker } from './msw/worker'; +import { drainQueryClients } from './render'; beforeAll(async () => { await worker.start({ onUnhandledRequest: 'error', quiet: true }); return () => worker.stop(); }); -afterEach(() => { +// Registered after setup.ts, so this runs first (afterEach is LIFO): +// unmount → cancel in-flight queries → reset handlers, then setup.ts +// restores stores and mocks. +afterEach(async () => { + await cleanup(); + await drainQueryClients(); worker.resetHandlers(); }); diff --git a/app/src/test/sse.ts b/app/src/test/sse.ts new file mode 100644 index 00000000..ec6c8821 --- /dev/null +++ b/app/src/test/sse.ts @@ -0,0 +1,81 @@ +import { HttpResponse } from 'msw'; + +export interface SseEvent { + data: unknown; + event?: string; +} + +const SSE_HEADERS = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', +} as const; + +const encoder = new TextEncoder(); + +function frame({ data, event }: SseEvent): Uint8Array { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + const lines = event ? `event: ${event}\ndata: ${payload}\n\n` : `data: ${payload}\n\n`; + return encoder.encode(lines); +} + +/** + * An MSW response streaming the given events immediately, then staying open + * (EventSource reconnects on close, so a closed stream would loop the test). + */ +export function sseResponse(events: SseEvent[]): Response { + const stream = new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(frame(event)); + }, + }); + return new HttpResponse(stream, { headers: SSE_HEADERS }); +} + +export interface SseController { + /** Hand this to an MSW resolver: `http.get(url, () => sse.response())`. */ + response(): Response; + /** Push one event to every open stream. */ + push(event: SseEvent): void; + /** End all open streams. */ + close(): void; +} + +/** + * Imperative SSE feed for tests that interleave user actions with server + * events (generation progress, download progress). Each call to `response()` + * opens a stream that receives subsequent `push`es — matching EventSource + * reconnect behavior. + */ +export function sseController(): SseController { + const controllers = new Set>(); + + return { + response() { + let own: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + own = controller; + controllers.add(controller); + }, + cancel() { + controllers.delete(own); + }, + }); + return new HttpResponse(stream, { headers: SSE_HEADERS }); + }, + push(event: SseEvent) { + for (const controller of controllers) controller.enqueue(frame(event)); + }, + close() { + for (const controller of controllers) { + try { + controller.close(); + } catch { + // already closed by cancel + } + } + controllers.clear(); + }, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index c51f4546..bee08954 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,6 +30,39 @@ export default defineConfig({ { ...shared, publicDir: path.resolve(__dirname, 'app/src/test/public'), + // Pre-bundle everything the app pulls in so the dep optimizer never + // reloads mid-run (a cold cache otherwise fails the first CI run). + optimizeDeps: { + include: [ + 'react', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@tanstack/react-query', + '@tanstack/react-router', + 'zustand', + 'zustand/middleware', + 'i18next', + 'react-i18next', + 'i18next-browser-languagedetector', + 'framer-motion', + 'motion/react', + 'lucide-react', + 'wavesurfer.js', + '@dnd-kit/core', + '@dnd-kit/sortable', + '@dnd-kit/utilities', + 'react-hook-form', + '@hookform/resolvers/zod', + 'zod', + 'clsx', + 'tailwind-merge', + 'class-variance-authority', + 'date-fns', + 'msw', + 'vitest-browser-react', + ], + }, test: { name: 'browser', include: ['app/src/**/*.browser.test.{ts,tsx}'],