diff --git a/app/src/App.browser.test.tsx b/app/src/App.browser.test.tsx
new file mode 100644
index 00000000..91c0b504
--- /dev/null
+++ b/app/src/App.browser.test.tsx
@@ -0,0 +1,134 @@
+import { mockIPC } from '@tauri-apps/api/mocks';
+import { afterEach, beforeEach, expect, it } from 'vitest';
+import App from '@/App';
+import { createMockPlatform } from '@/test/mockPlatform';
+import { buildModelStatus, buildProfile } from '@/test/msw/fixtures';
+import {
+ captureHandlers,
+ effectsHandlers,
+ historyHandlers,
+ modelHandlers,
+ profileHandlers,
+ settingsHandlers,
+ storyHandlers,
+ taskHandlers,
+} from '@/test/msw/handlers';
+import { worker } from '@/test/msw/worker';
+import { renderWithProviders } from '@/test/render';
+
+const originalUrl = window.location.href;
+
+// useChordSync and the permission gates call the Tauri IPC modules directly,
+// outside the Platform abstraction. There is no Tauri runtime in the test
+// browser, so `invoke`/`listen` would reject with a TypeError that some
+// callers (e.g. useChordSync's `listen('dictate:warm-request')`) never get a
+// chance to handle, surfacing as unhandled rejections. mockIPC installs the
+// official in-memory IPC shim; `shouldMockEvents` covers listen/emit too.
+//
+// Reinstalled per test for a fresh listener map, but never cleared: the
+// harness unmounts components after this file's afterEach, and those unmount
+// cleanups still `unlisten` through the shim. The per-file iframe throws the
+// window state away anyway.
+beforeEach(() => {
+ mockIPC(
+ (cmd) => {
+ // Permission checks treat the result as a trusted boolean — grant
+ // them so no permission banners pop over the UI under test.
+ if (cmd.startsWith('check_')) return true;
+ return null;
+ },
+ { shouldMockEvents: true },
+ );
+});
+
+afterEach(() => {
+ window.history.replaceState(null, '', originalUrl);
+ delete window.__voiceboxServerStartedByApp;
+});
+
+/**
+ * Everything the index route (MainEditor + app chrome) fetches on mount.
+ * Unstubbed requests fail the test loudly, so this is the full route budget.
+ */
+function useHappyPathHandlers() {
+ worker.use(
+ ...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
+ ...historyHandlers([]),
+ ...captureHandlers([]),
+ ...settingsHandlers(),
+ ...modelHandlers([buildModelStatus()]),
+ ...storyHandlers([]),
+ ...effectsHandlers([]),
+ ...taskHandlers(),
+ );
+}
+
+// App reads window.location at render time: `?view=dictate` picks the pill
+// window, and the router matches the real browser path. Point the URL at the
+// state under test before mounting; afterEach restores the runner's URL.
+function setAppUrl(path: string) {
+ window.history.replaceState(null, '', path);
+}
+
+it('skips the startup gate outside Tauri and renders the router', async () => {
+ useHappyPathHandlers();
+ setAppUrl('/');
+
+ const screen = await renderWithProviders();
+
+ // Index route is MainEditor — the profile list proves the router mounted.
+ await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
+
+ // Web mode assumes an external server: no lifecycle management at all.
+ expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
+ expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
+});
+
+it('skips server auto-start in Tauri dev mode and still reaches the router', async () => {
+ // App gates auto-start on `import.meta.env.PROD`, which is false under
+ // vitest. The reachable Tauri branch is therefore the dev one: window
+ // close handler installed, auto-start skipped, serverReady forced true.
+ //
+ // The PROD-only branches — `lifecycle.startServer`, the health-check
+ // polling fallback, and the startup-error screen with its Retry button —
+ // are unreachable here without mocking import.meta.env, so they are
+ // intentionally not covered.
+ useHappyPathHandlers();
+ setAppUrl('/');
+ const platform = createMockPlatform({ metadata: { isTauri: true } });
+
+ const screen = await renderWithProviders(, { platform });
+
+ await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
+
+ expect(platform.lifecycle.startServer).not.toHaveBeenCalled();
+ expect(platform.lifecycle.setupWindowCloseHandler).toHaveBeenCalled();
+ // Startup syncs the keep-server-running setting into Rust.
+ expect(platform.lifecycle.setKeepServerRunning).toHaveBeenCalledWith(expect.any(Boolean));
+ // Auto-updater runs its mount check in Tauri.
+ expect(platform.updater.checkForUpdates).toHaveBeenCalled();
+ // Dev mode records that the app does not own the server process.
+ expect(window.__voiceboxServerStartedByApp).toBe(false);
+});
+
+it('renders the dictate pill window for ?view=dictate without booting the main app', async () => {
+ // No route handlers on purpose: the dictate view must not touch any of the
+ // main app's endpoints, and an unhandled request would fail the test.
+ setAppUrl('/?view=dictate');
+
+ const screen = await renderWithProviders();
+
+ // DictateWindow forces the document transparent so the Tauri window takes
+ // the pill's shape — the observable signal that it mounted without
+ // throwing under the non-Tauri mock platform.
+ await expect.poll(() => document.body.style.background).toBe('transparent');
+
+ // The pill starts hidden: the wrapper renders but contains no CapturePill.
+ const wrapper = screen.container.firstElementChild as HTMLElement;
+ expect(wrapper.className).toContain('h-screen');
+ expect(wrapper.childElementCount).toBe(0);
+
+ // The startup gate never ran — no server lifecycle calls from this window.
+ expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
+ expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
+});
diff --git a/app/src/components/ChordPicker/ChordPicker.browser.test.tsx b/app/src/components/ChordPicker/ChordPicker.browser.test.tsx
new file mode 100644
index 00000000..5d05a337
--- /dev/null
+++ b/app/src/components/ChordPicker/ChordPicker.browser.test.tsx
@@ -0,0 +1,113 @@
+import { expect, it, vi } from 'vitest';
+import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
+import { renderWithProviders } from '@/test/render';
+
+// ChordPicker listens on window in the capture phase and canonicalizes via
+// `event.code`, so raw KeyboardEvents give exact control over which physical
+// keys the picker sees (userEvent would depend on the host keyboard layout).
+function press(code: string) {
+ window.dispatchEvent(new KeyboardEvent('keydown', { code, bubbles: true, cancelable: true }));
+}
+
+function release(code: string) {
+ window.dispatchEvent(new KeyboardEvent('keyup', { code, bubbles: true, cancelable: true }));
+}
+
+async function renderPicker(initialKeys: string[] = []) {
+ const onSave = vi.fn();
+ const onCancel = vi.fn();
+ const screen = await renderWithProviders(
+ ,
+ );
+ return { screen, onSave, onCancel };
+}
+
+it('opens empty with save disabled and flags unsupported keys', async () => {
+ const { screen } = await renderPicker();
+
+ await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
+ await expect.element(screen.getByText('No keys yet')).toBeVisible();
+ await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+
+ // NumpadEnter has no canonical chord name — the picker refuses it and
+ // stays empty instead of capturing garbage.
+ press('NumpadEnter');
+ await expect.element(screen.getByText(/isn't supported in chords/)).toBeVisible();
+ await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+});
+
+it('captures the held keys and saves them after release', async () => {
+ const { screen, onSave } = await renderPicker();
+
+ press('KeyJ');
+ await expect.element(screen.getByText('Capturing…')).toBeVisible();
+ await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
+
+ press('KeyK');
+ await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
+
+ // Releasing everything freezes the peak so the user can save hands-free.
+ release('KeyK');
+ release('KeyJ');
+ await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
+ await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
+ await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
+
+ await screen.getByRole('button', { name: 'Save' }).click();
+ expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyJ', 'KeyK']);
+});
+
+it('keeps the peak set when a key is released mid-chord', async () => {
+ const { screen, onSave } = await renderPicker();
+
+ press('KeyA');
+ press('KeyB');
+ press('KeyC');
+ await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
+
+ // Mid-chord the display tracks only the currently held keys...
+ release('KeyB');
+ await expect.element(screen.getByText('B', { exact: true })).not.toBeInTheDocument();
+ await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
+
+ // ...but the captured peak still includes the released key.
+ release('KeyA');
+ release('KeyC');
+ await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
+
+ await screen.getByRole('button', { name: 'Save' }).click();
+ expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyA', 'KeyB', 'KeyC']);
+});
+
+it('replaces a longer saved chord with a fresh shorter one', async () => {
+ const { screen, onSave } = await renderPicker(['KeyA', 'KeyB', 'KeyC']);
+
+ await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
+
+ // The first key of a new sequence resets the peak, so a single key can
+ // beat the three-key seed.
+ press('KeyZ');
+ release('KeyZ');
+ await expect.element(screen.getByText('Z', { exact: true })).toBeVisible();
+ await expect.element(screen.getByText('A', { exact: true })).not.toBeInTheDocument();
+
+ await screen.getByRole('button', { name: 'Save' }).click();
+ expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyZ']);
+});
+
+it('cancel fires the cancel callback and never saves', async () => {
+ const { screen, onSave, onCancel } = await renderPicker(['KeyA']);
+
+ press('KeyQ');
+ release('KeyQ');
+ await screen.getByRole('button', { name: 'Cancel' }).click();
+
+ expect(onCancel).toHaveBeenCalledOnce();
+ expect(onSave).not.toHaveBeenCalled();
+});
diff --git a/app/src/components/Generation/FloatingGenerateBox.browser.test.tsx b/app/src/components/Generation/FloatingGenerateBox.browser.test.tsx
new file mode 100644
index 00000000..012e6cec
--- /dev/null
+++ b/app/src/components/Generation/FloatingGenerateBox.browser.test.tsx
@@ -0,0 +1,185 @@
+import { HttpResponse, http } from 'msw';
+import { expect, it } from 'vitest';
+import type { VoiceProfileResponse } from '@/lib/api/types';
+import { useGenerationStore } from '@/stores/generationStore';
+import { useUIStore } from '@/stores/uiStore';
+import { buildGeneration, buildModelStatus, buildProfile } from '@/test/msw/fixtures';
+import {
+ captureHandlers,
+ effectsHandlers,
+ historyHandlers,
+ modelHandlers,
+ profileHandlers,
+ settingsHandlers,
+ storyHandlers,
+ taskHandlers,
+} from '@/test/msw/handlers';
+import { worker } from '@/test/msw/worker';
+import { renderRoute } from '@/test/render';
+import { sseController } from '@/test/sse';
+
+/**
+ * FloatingGenerateBox calls useMatchRoute, so it needs router context; the
+ * SSE completion loop (useGenerationProgress) lives in the router's root
+ * layout. Mounting the index route exercises the real wiring for both.
+ * History handlers are registered per test so requests can be counted.
+ */
+function stubAppRequests(profiles: VoiceProfileResponse[]) {
+ worker.use(
+ ...profileHandlers(profiles),
+ ...captureHandlers([]),
+ ...settingsHandlers(),
+ ...modelHandlers([buildModelStatus()]),
+ ...storyHandlers([]),
+ ...effectsHandlers([]),
+ ...taskHandlers(),
+ );
+}
+
+it('renders the generate box wired to the selected profile', async () => {
+ const profile = buildProfile({ name: 'Ada Lovelace' });
+ stubAppRequests([profile]);
+ worker.use(...historyHandlers([]));
+ useUIStore.getState().setSelectedProfileId(profile.id);
+
+ const screen = await renderRoute('/');
+
+ await expect
+ .element(screen.getByPlaceholder('Generate speech using Ada Lovelace…'))
+ .toBeVisible();
+ await expect.element(screen.getByRole('button', { name: 'Generate speech' })).toBeEnabled();
+ expect(useUIStore.getState().selectedProfileId).toBe(profile.id);
+});
+
+it('posts to /generate on submit and tracks the pending generation', async () => {
+ const profile = buildProfile({ name: 'Ada Lovelace' });
+ const generation = buildGeneration({
+ profile_id: profile.id,
+ status: 'generating',
+ audio_path: undefined,
+ });
+ const generateBodies: unknown[] = [];
+ const sse = sseController();
+ stubAppRequests([profile]);
+ worker.use(
+ ...historyHandlers([]),
+ http.post('*/generate', async ({ request }) => {
+ generateBodies.push(await request.json());
+ return HttpResponse.json(generation);
+ }),
+ http.get('*/generate/:id/status', () => sse.response()),
+ );
+ useUIStore.getState().setSelectedProfileId(profile.id);
+
+ const screen = await renderRoute('/');
+
+ const input = screen.getByPlaceholder('Generate speech using Ada Lovelace…');
+ await input.fill('Hello from the browser test');
+ await screen.getByRole('button', { name: 'Generate speech' }).click();
+
+ await expect.poll(() => generateBodies.length).toBe(1);
+ expect(generateBodies[0]).toMatchObject({
+ profile_id: profile.id,
+ text: 'Hello from the browser test',
+ language: 'en',
+ engine: 'qwen',
+ });
+ await expect
+ .poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
+ .toBe(true);
+ // The form resets as soon as the request is accepted.
+ await expect.element(input).toHaveValue('');
+ sse.close();
+});
+
+it('clears pending state and refetches history when SSE reports completion', async () => {
+ const profile = buildProfile({ name: 'Ada Lovelace' });
+ const generation = buildGeneration({
+ profile_id: profile.id,
+ status: 'generating',
+ audio_path: undefined,
+ });
+ const sse = sseController();
+ let sseConnections = 0;
+ let historyGets = 0;
+ stubAppRequests([profile]);
+ worker.use(
+ http.get('*/history', () => {
+ historyGets += 1;
+ return HttpResponse.json({ items: [], total: 0 });
+ }),
+ http.post('*/generate', () => HttpResponse.json(generation)),
+ http.get('*/generate/:id/status', () => {
+ sseConnections += 1;
+ return sse.response();
+ }),
+ // Autoplay is off via settingsHandlers, but keep audio stubbed so a
+ // completion-triggered player fetch could never fail the run loudly.
+ http.get(
+ '*/audio/:id',
+ () =>
+ new HttpResponse(new Blob([new Uint8Array(64)]), {
+ headers: { 'Content-Type': 'audio/wav' },
+ }),
+ ),
+ );
+ useUIStore.getState().setSelectedProfileId(profile.id);
+
+ const screen = await renderRoute('/');
+
+ await screen.getByPlaceholder('Generate speech using Ada Lovelace…').fill('Progress please');
+ await screen.getByRole('button', { name: 'Generate speech' }).click();
+
+ await expect
+ .poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
+ .toBe(true);
+ await expect.poll(() => sseConnections).toBe(1);
+ // Initial mount fetch + post-submit invalidation — wait for both so the
+ // final count increase can only come from the SSE completion refetch.
+ await expect.poll(() => historyGets).toBe(2);
+
+ sse.push({ data: { id: generation.id, status: 'generating' } });
+ sse.push({ data: { id: generation.id, status: 'completed', duration: 1.5 } });
+
+ await expect.poll(() => useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
+ await expect.poll(() => historyGets).toBe(3);
+ sse.close();
+});
+
+it('disables the input and generate button when no profile is selected', async () => {
+ stubAppRequests([]);
+ worker.use(...historyHandlers([]));
+
+ const screen = await renderRoute('/');
+
+ await expect
+ .element(screen.getByRole('button', { name: 'Select a voice profile first' }))
+ .toBeDisabled();
+ await expect.element(screen.getByPlaceholder('Select a voice profile above…')).toBeDisabled();
+});
+
+it('does not post to /generate when the text is empty', async () => {
+ const profile = buildProfile({ name: 'Ada Lovelace' });
+ let generateCalls = 0;
+ stubAppRequests([profile]);
+ worker.use(
+ ...historyHandlers([]),
+ http.post('*/generate', () => {
+ generateCalls += 1;
+ return HttpResponse.json(buildGeneration());
+ }),
+ );
+ useUIStore.getState().setSelectedProfileId(profile.id);
+
+ const screen = await renderRoute('/');
+
+ const button = screen.getByRole('button', { name: 'Generate speech' });
+ await expect.element(button).toBeEnabled();
+ await button.click();
+
+ // Validation rejects empty text before any request is made — give a
+ // would-be submission ample time to surface, then assert it never did.
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ expect(generateCalls).toBe(0);
+ expect(useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
+});
diff --git a/app/src/components/History/HistoryTable.browser.test.tsx b/app/src/components/History/HistoryTable.browser.test.tsx
new file mode 100644
index 00000000..85a66511
--- /dev/null
+++ b/app/src/components/History/HistoryTable.browser.test.tsx
@@ -0,0 +1,133 @@
+import { HttpResponse, http } from 'msw';
+import { expect, it, vi } from 'vitest';
+import { HistoryTable } from '@/components/History/HistoryTable';
+import { usePlayerStore } from '@/stores/playerStore';
+import { buildHistoryItem } from '@/test/msw/fixtures';
+import { historyHandlers } from '@/test/msw/handlers';
+import { worker } from '@/test/msw/worker';
+import { renderWithProviders } from '@/test/render';
+
+it('renders history rows with profile names and transcripts', async () => {
+ const ada = buildHistoryItem({
+ profile_name: 'Ada Lovelace',
+ text: 'The analytical engine speaks.',
+ });
+ const grace = buildHistoryItem({
+ profile_name: 'Grace Hopper',
+ text: 'A compiler for the spoken word.',
+ });
+ worker.use(...historyHandlers([ada, grace]));
+
+ const screen = await renderWithProviders();
+
+ await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
+ await expect.element(screen.getByText('Grace Hopper')).toBeVisible();
+ await expect
+ .element(screen.getByRole('textbox', { name: /Transcript for sample from Ada Lovelace/ }))
+ .toHaveValue('The analytical engine speaks.');
+ await expect
+ .element(screen.getByRole('textbox', { name: /Transcript for sample from Grace Hopper/ }))
+ .toHaveValue('A compiler for the spoken word.');
+});
+
+it('shows the empty state when there is no history', async () => {
+ worker.use(...historyHandlers([]));
+
+ const screen = await renderWithProviders();
+
+ await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
+});
+
+it('loads a clicked row into the player store with auto-play intent', async () => {
+ const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Play me back.' });
+ worker.use(...historyHandlers([item]));
+
+ const screen = await renderWithProviders();
+
+ // Click the profile-name cell — the row's mousedown handler ignores clicks
+ // that land on the transcript textarea.
+ await screen.getByText('Ada Lovelace').click();
+
+ await expect.poll(() => usePlayerStore.getState().audioId).toBe(item.id);
+ const player = usePlayerStore.getState();
+ expect(player.audioUrl).toContain(`/audio/${item.id}`);
+ expect(player.profileId).toBe(item.profile_id);
+ expect(player.shouldAutoPlay).toBe(true);
+ // isPlaying flips only once the AudioPlayer (not mounted here) starts playback.
+ expect(player.isPlaying).toBe(false);
+});
+
+it('toggles favorite via POST and reflects the refetched state', async () => {
+ const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
+ let favorited = false;
+ const favoriteRequests: string[] = [];
+ worker.use(
+ http.get('*/history', () =>
+ HttpResponse.json({ items: [{ ...item, is_favorited: favorited }], total: 1 }),
+ ),
+ http.post('*/history/:id/favorite', ({ params }) => {
+ favoriteRequests.push(params.id as string);
+ favorited = true;
+ return HttpResponse.json({ is_favorited: favorited });
+ }),
+ );
+
+ const screen = await renderWithProviders();
+
+ await screen.getByRole('button', { name: 'Favorite' }).click();
+
+ await expect.poll(() => favoriteRequests).toEqual([item.id]);
+ // History was invalidated and refetched — the star now reads as favorited.
+ await expect.element(screen.getByRole('button', { name: 'Unfavorite' })).toBeVisible();
+});
+
+it('deletes a generation after confirming the dialog', async () => {
+ const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
+ let items = [item];
+ const deleteRequests: string[] = [];
+ worker.use(
+ http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
+ http.delete('*/history/:id', ({ params }) => {
+ deleteRequests.push(params.id as string);
+ items = items.filter((i) => i.id !== params.id);
+ return HttpResponse.json({ status: 'deleted' });
+ }),
+ );
+
+ const screen = await renderWithProviders();
+
+ await screen.getByRole('button', { name: 'Actions' }).click();
+ await screen.getByRole('menuitem', { name: 'Delete' }).click();
+ await expect.element(screen.getByText('Delete Generation')).toBeVisible();
+ await screen.getByRole('button', { name: 'Delete' }).click();
+
+ await expect.poll(() => deleteRequests).toEqual([item.id]);
+ // The refetched (now empty) list replaces the row.
+ await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
+});
+
+it('exports audio through platform.filesystem.saveFile', async () => {
+ const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Export me please' });
+ worker.use(
+ ...historyHandlers([item]),
+ http.get(
+ '*/history/:id/export-audio',
+ () =>
+ new HttpResponse(new Blob([new Uint8Array(64)]), {
+ headers: { 'Content-Type': 'audio/wav' },
+ }),
+ ),
+ );
+
+ const screen = await renderWithProviders();
+
+ await screen.getByRole('button', { name: 'Actions' }).click();
+ await screen.getByRole('menuitem', { name: 'Export Audio' }).click();
+
+ const saveFile = vi.mocked(screen.platform.filesystem.saveFile);
+ await expect.poll(() => saveFile.mock.calls.length).toBe(1);
+ const [filename, blob, filters] = saveFile.mock.calls[0];
+ expect(filename).toBe('export-me-please.wav');
+ expect(blob).toBeInstanceOf(Blob);
+ expect(filters).toEqual([{ name: 'Audio File', extensions: ['wav'] }]);
+});