test: harness — typed fixtures, domain MSW handlers, SSE helpers, route render

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.
This commit is contained in:
Jamie Pine
2026-08-08 15:26:53 -07:00
parent fddcd61f4d
commit c7a3ff5d65
7 changed files with 528 additions and 1 deletions
+58
View File
@@ -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();
});
+216
View File
@@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {}): 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> = {},
): 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> = {},
): GenerationSettings {
return {
max_chunk_chars: 400,
crossfade_ms: 60,
normalize_audio: true,
autoplay_on_generate: false,
...overrides,
};
}
export function buildModelStatus(overrides: Partial<ModelStatus> = {}): 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> = {}): 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> = {}): 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> = {},
): StoryDetailResponse {
return {
id: 'story-0001',
name: 'Test Story',
created_at: CREATED_AT,
updated_at: CREATED_AT,
items: [],
...overrides,
};
}
export function buildEffectPreset(
overrides: Partial<EffectPresetResponse> = {},
): 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> = {}): HealthResponse {
return {
status: 'ok',
model_loaded: false,
gpu_available: false,
backend_variant: 'cpu',
...overrides,
};
}
+102
View File
@@ -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<CaptureSettings>;
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<GenerationSettings>;
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: [] }))];
}
+30
View File
@@ -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<void> {
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(
<QueryClientProvider client={queryClient}>
@@ -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(<RouterProvider router={router} />, options);
return Object.assign(result, { router });
}
+8 -1
View File
@@ -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();
});
+81
View File
@@ -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<Uint8Array>({
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<ReadableStreamDefaultController<Uint8Array>>();
return {
response() {
let own: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
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();
},
};
}