From c7a3ff5d65e75ca447e410f6bf9b01eb125886bc Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:26:53 -0700 Subject: [PATCH] =?UTF-8?q?test:=20harness=20=E2=80=94=20typed=20fixtures,?= =?UTF-8?q?=20domain=20MSW=20handlers,=20SSE=20helpers,=20route=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixture builders match the hand-written API types with deterministic ids. Handlers are per-domain factories tests compose via worker.use; unstubbed requests fail loudly. sseController streams real text/event-stream bodies through MSW into EventSource, so generation and download progress are testable without touching the transport. renderRoute mounts the real route tree over memory history. Query clients drain on teardown so in-flight fetches can't leak past handler reset, and the browser project pre-bundles app deps so a cold dep- optimizer cache can't reload mid-run. --- app/src/test/harness.browser.test.tsx | 58 +++++++ app/src/test/msw/fixtures.ts | 216 ++++++++++++++++++++++++++ app/src/test/msw/handlers/index.ts | 102 ++++++++++++ app/src/test/render.tsx | 30 ++++ app/src/test/setup.browser.ts | 9 +- app/src/test/sse.ts | 81 ++++++++++ vitest.config.ts | 33 ++++ 7 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 app/src/test/harness.browser.test.tsx create mode 100644 app/src/test/msw/fixtures.ts create mode 100644 app/src/test/msw/handlers/index.ts create mode 100644 app/src/test/sse.ts 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}'],