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'] }]); +});