diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 584788c06..c726c859b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,8 @@ on: - main jobs: - frontend-quality: + quality: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 @@ -22,5 +21,115 @@ jobs: - name: Typecheck app + web run: bun run typecheck - - name: Build web smoke test + - name: Build web run: bun run build:web + + - name: Upload web build + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist + retention-days: 1 + + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }} + + - name: Install Chromium + run: bunx playwright install chromium --with-deps + + - name: Vitest (unit + browser) + run: bunx vitest run + + e2e: + # Informational while the suite beds in; flip to blocking once it has + # a sustained green run. + continue-on-error: true + needs: quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: backend/requirements-ci.txt + + - name: Install backend (CPU) + run: | + pip install torch --index-url https://download.pytorch.org/whl/cpu + pip install -r backend/requirements-ci.txt + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }} + + - name: Install Chromium + run: bunx playwright install chromium --with-deps + + - name: Playwright E2E + run: bunx playwright test -c e2e + env: + VOICEBOX_PYTHON: python + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report + test-results + retention-days: 7 + + backend-tests: + # Informational: 30 pre-existing pytest files that have never run in CI. + continue-on-error: true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: backend/requirements-ci.txt + + - name: Install backend (CPU) + run: | + pip install torch --index-url https://download.pytorch.org/whl/cpu + pip install -r backend/requirements-ci.txt + pip install pytest pytest-asyncio + + - name: Pytest + run: python -m pytest backend/tests -v --ignore=backend/tests/test_all_models_e2e.py diff --git a/.gitignore b/.gitignore index 853c50609..3b6c46e14 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/.vitest-attachments/3cb88173a3c966ff32fd6131b2a7f621a51b4b8d.png b/.vitest-attachments/3cb88173a3c966ff32fd6131b2a7f621a51b4b8d.png new file mode 100644 index 000000000..4087dd2e4 Binary files /dev/null and b/.vitest-attachments/3cb88173a3c966ff32fd6131b2a7f621a51b4b8d.png differ diff --git a/app/index.html b/app/index.html deleted file mode 100644 index 2a155139a..000000000 --- a/app/index.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - voicebox - - - -
- - - diff --git a/app/package.json b/app/package.json index 56bf162a3..621155dff 100644 --- a/app/package.json +++ b/app/package.json @@ -4,10 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", - "build": "vite build", "typecheck": "tsc -p tsconfig.json --noEmit", - "preview": "vite preview", "lint": "biome lint src", "lint:fix": "biome lint --write src", "format": "biome format --write src", diff --git a/app/src/App.browser.test.tsx b/app/src/App.browser.test.tsx new file mode 100644 index 000000000..91c0b5044 --- /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/AudioTab/AudioTab.tsx b/app/src/components/AudioTab/AudioTab.tsx deleted file mode 100644 index 5f7549fbd..000000000 --- a/app/src/components/AudioTab/AudioTab.tsx +++ /dev/null @@ -1,675 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { apiClient } from '@/lib/api/client'; -import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; -import { cn } from '@/lib/utils/cn'; -import { usePlatform } from '@/platform/PlatformContext'; -import { usePlayerStore } from '@/stores/playerStore'; - -interface AudioDevice { - id: string; - name: string; - is_default: boolean; -} - -export function AudioTab() { - const { t } = useTranslation(); - const platform = usePlatform(); - const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [editingChannel, setEditingChannel] = useState(null); - const [selectedChannelId, setSelectedChannelId] = useState(null); - const queryClient = useQueryClient(); - const audioUrl = usePlayerStore((state) => state.audioUrl); - const isPlayerVisible = !!audioUrl; - - const { data: channels, isLoading: channelsLoading } = useQuery({ - queryKey: ['channels'], - queryFn: () => apiClient.listChannels(), - }); - - const { data: devices, isLoading: devicesLoading } = useQuery({ - queryKey: ['audio-devices'], - queryFn: async () => { - if (!platform.metadata.isTauri) { - return []; - } - try { - return await platform.audio.listOutputDevices(); - } catch (error) { - console.error('Failed to list audio devices:', error); - return []; - } - }, - enabled: platform.metadata.isTauri, - }); - - const { data: profiles } = useQuery({ - queryKey: ['profiles'], - queryFn: () => apiClient.listProfiles(), - }); - - const createChannel = useMutation({ - mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['channels'] }); - setCreateDialogOpen(false); - }, - }); - - const updateChannel = useMutation({ - mutationFn: ({ - channelId, - data, - }: { - channelId: string; - data: { name?: string; device_ids?: string[] }; - }) => apiClient.updateChannel(channelId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['channels'] }); - queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); - setEditingChannel(null); - }, - }); - - const deleteChannel = useMutation({ - mutationFn: (channelId: string) => apiClient.deleteChannel(channelId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['channels'] }); - queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); - }, - }); - - const { data: channelVoices } = useQuery({ - queryKey: ['channel-voices', editingChannel], - queryFn: async () => { - if (!editingChannel) return { profile_ids: [] }; - return apiClient.getChannelVoices(editingChannel); - }, - enabled: !!editingChannel, - }); - - const setChannelVoices = useMutation({ - mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) => - apiClient.setChannelVoices(channelId, profileIds), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['channel-voices'] }); - queryClient.invalidateQueries({ queryKey: ['profile-channels'] }); - }, - }); - - if (channelsLoading || devicesLoading) { - return ( -
-
{t('audioChannels.loading')}
-
- ); - } - - const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => { - e.stopPropagation(); - if (await confirm(t('audioChannels.confirmDelete'))) { - deleteChannel.mutate(channelId); - } - }; - - const allChannels = channels || []; - const allDevices = devices || []; - const selectedChannel = selectedChannelId - ? allChannels.find((c) => c.id === selectedChannelId) - : null; - - return ( -
-
-

{t('audioChannels.title')}

- -
- -
- {/* Left Column - Channels */} -
- {allChannels.length === 0 ? ( -
- -

{t('audioChannels.empty.message')}

- -
- ) : ( -
- {allChannels.map((channel) => { - const isSelected = selectedChannelId === channel.id; - return ( - - -
- )} -
- - ); - })} -
- )} -
- - {/* Right Column - Available Devices */} -
-
-

{t('audioChannels.devices.title')}

-

- {selectedChannelId - ? selectedChannel?.is_default - ? t('audioChannels.devices.defaultNote') - : t('audioChannels.devices.toggleHint') - : t('audioChannels.devices.selectHint')} -

-
- {allDevices.length > 0 ? ( -
- {allDevices.map((device) => { - const isConnected = - selectedChannelId && - selectedChannel && - (selectedChannel.device_ids.length === 0 - ? device.is_default - : selectedChannel.device_ids.includes(device.id)); - const canToggle = - selectedChannelId && selectedChannel && !selectedChannel.is_default; - - const handleDeviceClick = () => { - if (!canToggle || !selectedChannel) return; - - const currentDeviceIds = selectedChannel.device_ids; - const newDeviceIds = isConnected - ? currentDeviceIds.filter((id) => id !== device.id) - : [...currentDeviceIds, device.id]; - - updateChannel.mutate({ - channelId: selectedChannelId, - data: { device_ids: newDeviceIds }, - }); - }; - - return ( - - ); - })} -
- ) : ( -
- -

- {platform.metadata.isTauri - ? t('audioChannels.devices.empty') - : t('audioChannels.devices.requiresTauri')} -

-
- )} -
- - - {/* Create Channel Dialog */} - { - createChannel.mutate({ name, device_ids: deviceIds }); - }} - /> - - {/* Edit Channel Dialog */} - {editingChannel && - (() => { - const channel = channels?.find((c) => c.id === editingChannel); - return channel ? ( - !open && setEditingChannel(null)} - channel={channel} - devices={devices || []} - profiles={profiles || []} - channelVoices={channelVoices?.profile_ids || []} - onUpdate={(name, deviceIds) => { - updateChannel.mutate({ - channelId: editingChannel, - data: { name, device_ids: deviceIds }, - }); - }} - onSetVoices={(profileIds) => { - setChannelVoices.mutate({ - channelId: editingChannel, - profileIds, - }); - }} - /> - ) : null; - })()} - - ); -} - -function ChannelVoicesList({ channelId }: { channelId: string }) { - const { t } = useTranslation(); - const { data: voices } = useQuery({ - queryKey: ['channel-voices', channelId], - queryFn: () => apiClient.getChannelVoices(channelId), - }); - - const { data: profiles } = useQuery({ - queryKey: ['profiles'], - queryFn: () => apiClient.listProfiles(), - }); - - const voiceNames = - voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || []; - - return ( -
- {voiceNames.length > 0 ? ( - voiceNames.map((name) => ( - - {name} - - )) - ) : ( - {t('audioChannels.noVoicesAssigned')} - )} -
- ); -} - -interface CreateChannelDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - devices: AudioDevice[]; - onCreate: (name: string, deviceIds: string[]) => void; -} - -function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) { - const { t } = useTranslation(); - const [name, setName] = useState(''); - const [selectedDevices, setSelectedDevices] = useState([]); - - const handleSubmit = () => { - if (name.trim()) { - onCreate(name.trim(), selectedDevices); - setName(''); - setSelectedDevices([]); - } - }; - - return ( - - - - {t('audioChannels.createDialog.title')} - {t('audioChannels.createDialog.description')} - -
-
- - setName(e.target.value)} - placeholder={t('audioChannels.fields.namePlaceholder')} - /> -
-
- - - {selectedDevices.length > 0 && ( -
- {selectedDevices.map((deviceId) => { - const device = devices.find((d) => d.id === deviceId); - return ( -
- {device?.name || deviceId} - -
- ); - })} -
- )} -
-
- - - - -
-
- ); -} - -interface EditChannelDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - channel: { - id: string; - name: string; - device_ids: string[]; - }; - devices: AudioDevice[]; - profiles: Array<{ id: string; name: string }>; - channelVoices: string[]; - onUpdate: (name: string, deviceIds: string[]) => void; - onSetVoices: (profileIds: string[]) => void; -} - -function EditChannelDialog({ - open, - onOpenChange, - channel, - devices, - profiles, - channelVoices, - onUpdate, - onSetVoices, -}: EditChannelDialogProps) { - const { t } = useTranslation(); - const [name, setName] = useState(channel.name); - const [selectedDevices, setSelectedDevices] = useState(channel.device_ids); - const [selectedVoices, setSelectedVoices] = useState(channelVoices); - - const handleSubmit = () => { - if (name.trim()) { - onUpdate(name.trim(), selectedDevices); - onSetVoices(selectedVoices); - } - }; - - return ( - - - - {t('audioChannels.editDialog.title')} - {t('audioChannels.editDialog.description')} - -
-
- - setName(e.target.value)} /> -
-
- - - {selectedDevices.length > 0 && ( -
- {selectedDevices.map((deviceId) => { - const device = devices.find((d) => d.id === deviceId); - return ( -
- {device?.name || deviceId} - -
- ); - })} -
- )} -
-
- - - {selectedVoices.length > 0 && ( -
- {selectedVoices.map((profileId) => { - const profile = profiles.find((p) => p.id === profileId); - return ( -
- {profile?.name || profileId} - -
- ); - })} -
- )} -
-
- - - - -
-
- ); -} diff --git a/app/src/components/CapturesTab/CapturesTab.tsx b/app/src/components/CapturesTab/CapturesTab.tsx index 7492d320d..bb54c2fc5 100644 --- a/app/src/components/CapturesTab/CapturesTab.tsx +++ b/app/src/components/CapturesTab/CapturesTab.tsx @@ -1,8 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; -import { save } from '@tauri-apps/plugin-dialog'; -import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs'; import { Captions, Check, @@ -27,6 +25,14 @@ import { AudioBars } from '@/components/AudioBars'; import { CapturePill } from '@/components/CapturePill/CapturePill'; import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer'; import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist'; +import { + ListPane, + ListPaneHeader, + ListPaneScroll, + ListPaneSearch, + ListPaneTitle, + ListPaneTitleRow, +} from '@/components/ListPane'; import { AlertDialog, AlertDialogAction, @@ -48,14 +54,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Textarea } from '@/components/ui/textarea'; -import { - ListPane, - ListPaneHeader, - ListPaneScroll, - ListPaneSearch, - ListPaneTitle, - ListPaneTitleRow, -} from '@/components/ListPane'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import type { @@ -72,6 +70,7 @@ import { useCaptureSettings } from '@/lib/hooks/useSettings'; import { cn } from '@/lib/utils/cn'; import { formatAbsoluteDate, formatDate } from '@/lib/utils/format'; import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes'; +import { usePlatform } from '@/platform/PlatformContext'; import { useGenerationStore } from '@/stores/generationStore'; import { usePlayerStore } from '@/stores/playerStore'; @@ -135,6 +134,7 @@ export function CapturesTab() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { toast } = useToast(); + const platform = usePlatform(); const fileInputRef = useRef(null); const uploadInputRef = useRef(null); @@ -202,6 +202,7 @@ export function CapturesTab() { // the race window between ``setSelectedId(new)`` and the refetched list // actually containing the new row. useEffect(() => { + if (!platform.metadata.isTauri) return; const unlistens: Promise[] = []; unlistens.push( listen<{ capture: CaptureResponse }>('capture:created', (event) => { @@ -225,7 +226,7 @@ export function CapturesTab() { return () => { for (const p of unlistens) p.then((fn) => fn()).catch(() => {}); }; - }, [queryClient]); + }, [queryClient, platform.metadata.isTauri]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); @@ -243,9 +244,7 @@ export function CapturesTab() { // referenced profile was deleted) fall through to the first profile. const storedVoiceId = captureSettings?.default_playback_voice_id ?? null; const playAsVoice = - (storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || - profiles?.[0] || - null; + (storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || profiles?.[0] || null; const playAsVoiceId = playAsVoice?.id ?? null; const deleteMutation = useMutation({ @@ -255,12 +254,22 @@ export function CapturesTab() { queryClient.invalidateQueries({ queryKey: ['captures'] }); }, onError: (err: Error) => { - toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' }); + toast({ + title: t('captures.toast.deleteFailed'), + description: err.message, + variant: 'destructive', + }); }, }); const playAsMutation = useMutation({ - mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => { + mutationFn: async ({ + capture, + voice, + }: { + capture: CaptureResponse; + voice: VoiceProfileResponse; + }) => { const text = capture.transcript_refined || capture.transcript_raw; if (!text.trim()) throw new Error(t('captures.noTranscriptError')); const language = (capture.language || voice.language) as LanguageCode; @@ -268,8 +277,13 @@ export function CapturesTab() { // profile's stored engine preference. Cloned profiles without an // override fall through to whatever the backend picks. const engine = voice.default_engine as - | 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox' - | 'chatterbox_turbo' | 'tada' | 'kokoro' + | 'qwen' + | 'qwen_custom_voice' + | 'luxtts' + | 'chatterbox' + | 'chatterbox_turbo' + | 'tada' + | 'kokoro' | undefined; return apiClient.generateSpeech({ profile_id: voice.id, @@ -286,7 +300,11 @@ export function CapturesTab() { addPendingGeneration(result.id); }, onError: (err: Error) => { - toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' }); + toast({ + title: t('captures.toast.playAsFailed'), + description: err.message, + variant: 'destructive', + }); }, }); @@ -336,16 +354,15 @@ export function CapturesTab() { const handleExportAudio = async () => { if (!selected) return; try { - const dest = await save({ - defaultPath: `capture_${selected.id.slice(0, 8)}.wav`, - filters: [{ name: 'Audio', extensions: ['wav'] }], - }); - if (!dest) return; const res = await fetch(apiClient.getCaptureAudioUrl(selected.id)); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const buf = new Uint8Array(await res.arrayBuffer()); - await writeFile(dest, buf); - exportToastSuccess(dest); + const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' }); + const dest = await platform.filesystem.saveFile( + `capture_${selected.id.slice(0, 8)}.wav`, + blob, + [{ name: 'Audio', extensions: ['wav'] }], + ); + if (dest) exportToastSuccess(dest); } catch (err) { exportToastError(err); } @@ -359,13 +376,12 @@ export function CapturesTab() { return; } try { - const dest = await save({ - defaultPath: `capture_${selected.id.slice(0, 8)}.txt`, - filters: [{ name: 'Text', extensions: ['txt'] }], - }); - if (!dest) return; - await writeTextFile(dest, text); - exportToastSuccess(dest); + const dest = await platform.filesystem.saveFile( + `capture_${selected.id.slice(0, 8)}.txt`, + new Blob([text], { type: 'text/plain' }), + [{ name: 'Text', extensions: ['txt'] }], + ); + if (dest) exportToastSuccess(dest); } catch (err) { exportToastError(err); } @@ -376,7 +392,8 @@ export function CapturesTab() { lines.push(`# Capture ${capture.id}`, ''); lines.push(`- **Source:** ${capture.source}`); lines.push(`- **Created:** ${capture.created_at}`); - if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`); + if (capture.duration_ms != null) + lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`); if (capture.language) lines.push(`- **Language:** ${capture.language}`); if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`); if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`); @@ -398,13 +415,12 @@ export function CapturesTab() { return; } try { - const dest = await save({ - defaultPath: `capture_${selected.id.slice(0, 8)}.md`, - filters: [{ name: 'Markdown', extensions: ['md'] }], - }); - if (!dest) return; - await writeTextFile(dest, buildCaptureMarkdown(selected)); - exportToastSuccess(dest); + const dest = await platform.filesystem.saveFile( + `capture_${selected.id.slice(0, 8)}.md`, + new Blob([buildCaptureMarkdown(selected)], { type: 'text/markdown' }), + [{ name: 'Markdown', extensions: ['md'] }], + ); + if (dest) exportToastSuccess(dest); } catch (err) { exportToastError(err); } @@ -486,48 +502,48 @@ export function CapturesTab() { ) : ( filtered.map((capture) => { - const isActive = selectedId === capture.id; - const refined = !!capture.transcript_refined; - return ( - - ); - }) - )} + > +
+ + {formatDate(capture.created_at)} + +
+ + {formatDuration(capture.duration_ms)} + +
+
+ {snippetOf(capture)} +
+
+ + {refined && ( + + + {t('captures.transcript.refined')} + + )} +
+ + ); + }) + )}
@@ -578,7 +594,9 @@ export function CapturesTab() { ) : ( )} - {session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')} + {session.isUploading + ? t('captures.actions.importing') + : t('captures.actions.import')} )} @@ -748,11 +766,7 @@ export function CapturesTab() { {profiles?.map((v) => ( - handlePlayAs(v)} - className="py-2" - > + handlePlayAs(v)} className="py-2">
{v.name}
@@ -864,9 +878,7 @@ export function CapturesTab() {
) : null}
-

- {t('captures.empty.pressShortcut')} -

+

{t('captures.empty.pressShortcut')}

) : (
@@ -888,7 +900,9 @@ export function CapturesTab() { {t('captures.deleteDialog.title')} - {t('captures.deleteDialog.description')} + + {t('captures.deleteDialog.description')} + {t('common.cancel')} @@ -898,7 +912,9 @@ export function CapturesTab() { disabled={deleteMutation.isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')} + {deleteMutation.isPending + ? t('captures.deleteDialog.deleting') + : t('common.delete')} 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 000000000..5d05a3379 --- /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/DictateWindow/DictateWindow.tsx b/app/src/components/DictateWindow/DictateWindow.tsx index 1d917e696..6766b01d2 100644 --- a/app/src/components/DictateWindow/DictateWindow.tsx +++ b/app/src/components/DictateWindow/DictateWindow.tsx @@ -5,6 +5,7 @@ import { CapturePill } from '@/components/CapturePill/CapturePill'; import { apiClient } from '@/lib/api/client'; import type { FocusSnapshot } from '@/lib/api/types'; import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession'; +import { usePlatform } from '@/platform/PlatformContext'; /** * Floating dictate surface shown in a separate transparent Tauri window. @@ -22,6 +23,9 @@ import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSessi * ``dictate:hide`` so Rust tucks the window away. */ export function DictateWindow() { + const platform = usePlatform(); + const isTauri = platform.metadata.isTauri; + // Force the host document chrome to be transparent so the Tauri window // takes on the pill's own shape. useEffect(() => { @@ -70,6 +74,7 @@ export function DictateWindow() { sessionRef.current = session; useEffect(() => { + if (!isTauri) return; let disposed = false; const unlistens: UnlistenFn[] = []; const registrations = [ @@ -98,7 +103,7 @@ export function DictateWindow() { disposed = true; for (const unlisten of unlistens) unlisten(); }; - }, []); + }, [isTauri]); useEffect(() => { if (micWarm) void session.prewarm(); @@ -157,9 +162,7 @@ export function DictateWindow() { audio.onplaying = () => { emit('dictate:show').catch(() => {}); setSpeaking((prev) => - prev && prev.generationId === generationId - ? { ...prev, startedAt: Date.now() } - : prev, + prev && prev.generationId === generationId ? { ...prev, startedAt: Date.now() } : prev, ); setSpeakElapsed(0); }; @@ -171,6 +174,7 @@ export function DictateWindow() { }; useEffect(() => { + if (!isTauri) return; const unlistens: Promise[] = []; // Rust emits the SSE payload as a JSON *string* (not a parsed object); @@ -265,7 +269,7 @@ export function DictateWindow() { for (const p of unlistens) p.then((fn) => fn()).catch(() => {}); dismissSpeak(); }; - }, []); + }, [isTauri]); // Advance the pill's elapsed-time label while audio is playing. Paused // during the pre-playback generation window (startedAt is null) so the 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 000000000..012e6cec3 --- /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 000000000..85a66511e --- /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'] }]); +}); diff --git a/app/src/components/ServerTab/AboutPage.browser.test.tsx b/app/src/components/ServerTab/AboutPage.browser.test.tsx new file mode 100644 index 000000000..1bff9aa29 --- /dev/null +++ b/app/src/components/ServerTab/AboutPage.browser.test.tsx @@ -0,0 +1,15 @@ +import { expect, it } from 'vitest'; +import { AboutPage } from '@/components/ServerTab/AboutPage'; +import { createMockPlatform } from '@/test/mockPlatform'; +import { renderWithProviders } from '@/test/render'; + +it('renders and shows the platform version', async () => { + const platform = createMockPlatform({ + metadata: { getVersion: async () => '9.9.9-test', isTauri: false }, + }); + + const screen = await renderWithProviders(, { platform }); + + await expect.element(screen.getByAltText('Voicebox')).toBeVisible(); + await expect.element(screen.getByText('9.9.9-test', { exact: false })).toBeVisible(); +}); diff --git a/app/src/components/ServerTab/__screenshots__/AboutPage.browser.test.tsx/renders-and-shows-the-platform-version-1.png b/app/src/components/ServerTab/__screenshots__/AboutPage.browser.test.tsx/renders-and-shows-the-platform-version-1.png new file mode 100644 index 000000000..4087dd2e4 Binary files /dev/null and b/app/src/components/ServerTab/__screenshots__/AboutPage.browser.test.tsx/renders-and-shows-the-platform-version-1.png differ diff --git a/app/src/hooks/useAutoUpdater.ts b/app/src/hooks/useAutoUpdater.ts deleted file mode 100644 index b46764c8e..000000000 --- a/app/src/hooks/useAutoUpdater.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { usePlatform } from '@/platform/PlatformContext'; -import type { UpdateStatus } from '@/platform/types'; - -// Re-export UpdateStatus for backwards compatibility -export type { UpdateStatus }; - -interface UseAutoUpdaterOptions { - checkOnMount?: boolean; - showToast?: boolean; -} - -export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) { - const { checkOnMount } = - typeof options === 'boolean' ? { checkOnMount: options } : { checkOnMount: options.checkOnMount ?? false }; - - const platform = usePlatform(); - const [status, setStatus] = useState(platform.updater.getStatus()); - const hasCheckedRef = useRef(false); - - // Subscribe to updater status changes - useEffect(() => { - const unsubscribe = platform.updater.subscribe((newStatus) => { - setStatus(newStatus); - }); - return unsubscribe; - // Empty dependency array - platform is stable from context - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [platform.updater.subscribe]); - - const checkForUpdates = useCallback(async () => { - await platform.updater.checkForUpdates(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [platform.updater.checkForUpdates]); - - const downloadAndInstall = useCallback(async () => { - await platform.updater.downloadAndInstall(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [platform.updater.downloadAndInstall]); - - const restartAndInstall = useCallback(async () => { - await platform.updater.restartAndInstall(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [platform.updater.restartAndInstall]); - - useEffect(() => { - if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) { - hasCheckedRef.current = true; - checkForUpdates().catch((error) => { - console.error('Auto update check failed:', error); - }); - } - }, [checkOnMount, checkForUpdates, platform.metadata.isTauri]); - - return { - status, - checkForUpdates, - downloadAndInstall, - restartAndInstall, - }; -} diff --git a/app/src/lib/api/core/ApiError.ts b/app/src/lib/api/core/ApiError.ts deleted file mode 100644 index 657a30a30..000000000 --- a/app/src/lib/api/core/ApiError.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; - -export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: any; - public readonly request: ApiRequestOptions; - - constructor(request: ApiRequestOptions, response: ApiResult, message: string) { - super(message); - - this.name = 'ApiError'; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } -} diff --git a/app/src/lib/api/core/ApiRequestOptions.ts b/app/src/lib/api/core/ApiRequestOptions.ts deleted file mode 100644 index 40aab6c03..000000000 --- a/app/src/lib/api/core/ApiRequestOptions.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiRequestOptions = { - readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; - readonly url: string; - readonly path?: Record; - readonly cookies?: Record; - readonly headers?: Record; - readonly query?: Record; - readonly formData?: Record; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly errors?: Record; -}; diff --git a/app/src/lib/api/core/ApiResult.ts b/app/src/lib/api/core/ApiResult.ts deleted file mode 100644 index 24c93fc1b..000000000 --- a/app/src/lib/api/core/ApiResult.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ApiResult = { - readonly url: string; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly body: any; -}; diff --git a/app/src/lib/api/core/CancelablePromise.ts b/app/src/lib/api/core/CancelablePromise.ts deleted file mode 100644 index d94a263e9..000000000 --- a/app/src/lib/api/core/CancelablePromise.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export class CancelError extends Error { - constructor(message: string) { - super(message); - this.name = 'CancelError'; - } - - public get isCancelled(): boolean { - return true; - } -} - -export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; - - (cancelHandler: () => void): void; -} - -export class CancelablePromise implements Promise { - #isResolved: boolean; - #isRejected: boolean; - #isCancelled: boolean; - readonly #cancelHandlers: (() => void)[]; - readonly #promise: Promise; - #resolve?: (value: T | PromiseLike) => void; - #reject?: (reason?: any) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike) => void, - reject: (reason?: any) => void, - onCancel: OnCancel, - ) => void, - ) { - this.#isResolved = false; - this.#isRejected = false; - this.#isCancelled = false; - this.#cancelHandlers = []; - this.#promise = new Promise((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - - const onResolve = (value: T | PromiseLike): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isResolved = true; - if (this.#resolve) this.#resolve(value); - }; - - const onReject = (reason?: any): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isRejected = true; - if (this.#reject) this.#reject(reason); - }; - - const onCancel = (cancelHandler: () => void): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, 'isResolved', { - get: (): boolean => this.#isResolved, - }); - - Object.defineProperty(onCancel, 'isRejected', { - get: (): boolean => this.#isRejected, - }); - - Object.defineProperty(onCancel, 'isCancelled', { - get: (): boolean => this.#isCancelled, - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return 'Cancellable Promise'; - } - - public then( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike) | null, - ): Promise { - return this.#promise.then(onFulfilled, onRejected); - } - - public catch( - onRejected?: ((reason: any) => TResult | PromiseLike) | null, - ): Promise { - return this.#promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise { - return this.#promise.finally(onFinally); - } - - public cancel(): void { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isCancelled = true; - if (this.#cancelHandlers.length) { - try { - for (const cancelHandler of this.#cancelHandlers) { - cancelHandler(); - } - } catch (error) { - console.warn('Cancellation threw an error', error); - return; - } - } - this.#cancelHandlers.length = 0; - if (this.#reject) this.#reject(new CancelError('Request aborted')); - } - - public get isCancelled(): boolean { - return this.#isCancelled; - } -} diff --git a/app/src/lib/api/core/OpenAPI.ts b/app/src/lib/api/core/OpenAPI.ts deleted file mode 100644 index a7242371e..000000000 --- a/app/src/lib/api/core/OpenAPI.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ApiRequestOptions } from './ApiRequestOptions'; - -type Resolver = (options: ApiRequestOptions) => Promise; -type Headers = Record; - -export type OpenAPIConfig = { - BASE: string; - VERSION: string; - WITH_CREDENTIALS: boolean; - CREDENTIALS: 'include' | 'omit' | 'same-origin'; - TOKEN?: string | Resolver | undefined; - USERNAME?: string | Resolver | undefined; - PASSWORD?: string | Resolver | undefined; - HEADERS?: Headers | Resolver | undefined; - ENCODE_PATH?: ((path: string) => string) | undefined; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: '', - VERSION: '0.1.0', - WITH_CREDENTIALS: false, - CREDENTIALS: 'include', - TOKEN: undefined, - USERNAME: undefined, - PASSWORD: undefined, - HEADERS: undefined, - ENCODE_PATH: undefined, -}; diff --git a/app/src/lib/api/core/request.ts b/app/src/lib/api/core/request.ts deleted file mode 100644 index ac97e19be..000000000 --- a/app/src/lib/api/core/request.ts +++ /dev/null @@ -1,341 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import { ApiError } from './ApiError'; -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; -import { CancelablePromise } from './CancelablePromise'; -import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; - -export const isDefined = ( - value: T | null | undefined, -): value is Exclude => { - return value !== undefined && value !== null; -}; - -export const isString = (value: any): value is string => { - return typeof value === 'string'; -}; - -export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; -}; - -export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); -}; - -export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; -}; - -export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } -}; - -export const getQueryString = (params: Record): string => { - const qs: string[] = []; - - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach((v) => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; - - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); - - if (qs.length > 0) { - return `?${qs.join('&')}`; - } - - return ''; -}; - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; -}; - -export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((v) => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; -}; - -type Resolver = (options: ApiRequestOptions) => Promise; - -export const resolve = async ( - options: ApiRequestOptions, - resolver?: T | Resolver, -): Promise => { - if (typeof resolver === 'function') { - return (resolver as Resolver)(options); - } - return resolver; -}; - -export const getHeaders = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, -): Promise => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers, - }) - .filter(([_, value]) => isDefined(value)) - .reduce( - (headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), - {} as Record, - ); - - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; - } - } - - return new Headers(headers); -}; - -export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body !== undefined) { - if (options.mediaType?.includes('/json')) { - return JSON.stringify(options.body); - } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { - return options.body; - } else { - return JSON.stringify(options.body); - } - } - return undefined; -}; - -export const sendRequest = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Headers, - onCancel: OnCancel, -): Promise => { - const controller = new AbortController(); - - const request: RequestInit = { - headers, - body: body ?? formData, - method: options.method, - signal: controller.signal, - }; - - if (config.WITH_CREDENTIALS) { - request.credentials = config.CREDENTIALS; - } - - onCancel(() => controller.abort()); - - return await fetch(url, request); -}; - -export const getResponseHeader = ( - response: Response, - responseHeader?: string, -): string | undefined => { - if (responseHeader) { - const content = response.headers.get(responseHeader); - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = async (response: Response): Promise => { - if (response.status !== 204) { - try { - const contentType = response.headers.get('Content-Type'); - if (contentType) { - const jsonTypes = ['application/json', 'application/problem+json']; - const isJSON = jsonTypes.some((type) => contentType.toLowerCase().startsWith(type)); - if (isJSON) { - return await response.json(); - } else { - return await response.text(); - } - } - } catch (error) { - console.error(error); - } - } - return undefined; -}; - -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors, - }; - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError( - options, - result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`, - ); - } -}; - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @returns CancelablePromise - * @throws ApiError - */ -export const request = ( - config: OpenAPIConfig, - options: ApiRequestOptions, -): CancelablePromise => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options); - - if (!onCancel.isCancelled) { - const response = await sendRequest(config, options, url, body, formData, headers, onCancel); - const responseBody = await getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: response.ok, - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; diff --git a/app/src/lib/api/index.ts b/app/src/lib/api/index.ts deleted file mode 100644 index 58d71cd42..000000000 --- a/app/src/lib/api/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export { ApiError } from './core/ApiError'; -export { CancelablePromise, CancelError } from './core/CancelablePromise'; -export { OpenAPI } from './core/OpenAPI'; -export type { OpenAPIConfig } from './core/OpenAPI'; - -export type { Body_add_profile_sample_profiles__profile_id__samples_post } from './models/Body_add_profile_sample_profiles__profile_id__samples_post'; -export type { Body_transcribe_audio_transcribe_post } from './models/Body_transcribe_audio_transcribe_post'; -export type { GenerationRequest } from './models/GenerationRequest'; -export type { GenerationResponse } from './models/GenerationResponse'; -export type { HealthResponse } from './models/HealthResponse'; -export type { HistoryListResponse } from './models/HistoryListResponse'; -export type { HistoryResponse } from './models/HistoryResponse'; -export type { HTTPValidationError } from './models/HTTPValidationError'; -export type { ModelDownloadRequest } from './models/ModelDownloadRequest'; -export type { ModelStatus } from './models/ModelStatus'; -export type { ModelStatusListResponse } from './models/ModelStatusListResponse'; -export type { ProfileSampleResponse } from './models/ProfileSampleResponse'; -export type { TranscriptionResponse } from './models/TranscriptionResponse'; -export type { ValidationError } from './models/ValidationError'; -export type { VoiceProfileCreate } from './models/VoiceProfileCreate'; -export type { VoiceProfileResponse } from './models/VoiceProfileResponse'; - -export { $Body_add_profile_sample_profiles__profile_id__samples_post } from './schemas/$Body_add_profile_sample_profiles__profile_id__samples_post'; -export { $Body_transcribe_audio_transcribe_post } from './schemas/$Body_transcribe_audio_transcribe_post'; -export { $GenerationRequest } from './schemas/$GenerationRequest'; -export { $GenerationResponse } from './schemas/$GenerationResponse'; -export { $HealthResponse } from './schemas/$HealthResponse'; -export { $HistoryListResponse } from './schemas/$HistoryListResponse'; -export { $HistoryResponse } from './schemas/$HistoryResponse'; -export { $HTTPValidationError } from './schemas/$HTTPValidationError'; -export { $ModelDownloadRequest } from './schemas/$ModelDownloadRequest'; -export { $ModelStatus } from './schemas/$ModelStatus'; -export { $ModelStatusListResponse } from './schemas/$ModelStatusListResponse'; -export { $ProfileSampleResponse } from './schemas/$ProfileSampleResponse'; -export { $TranscriptionResponse } from './schemas/$TranscriptionResponse'; -export { $ValidationError } from './schemas/$ValidationError'; -export { $VoiceProfileCreate } from './schemas/$VoiceProfileCreate'; -export { $VoiceProfileResponse } from './schemas/$VoiceProfileResponse'; - -export { DefaultService } from './services/DefaultService'; diff --git a/app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts b/app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts deleted file mode 100644 index 7229998b9..000000000 --- a/app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Body_add_profile_sample_profiles__profile_id__samples_post = { - file: Blob; - reference_text: string; -}; diff --git a/app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts b/app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts deleted file mode 100644 index b5851c86c..000000000 --- a/app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type Body_transcribe_audio_transcribe_post = { - file: Blob; - language?: string | null; -}; diff --git a/app/src/lib/api/models/GenerationRequest.ts b/app/src/lib/api/models/GenerationRequest.ts deleted file mode 100644 index 9c0e8bb0b..000000000 --- a/app/src/lib/api/models/GenerationRequest.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Request model for voice generation. - */ -export type GenerationRequest = { - profile_id: string; - text: string; - language?: string; - seed?: number | null; - model_size?: string | null; - instruct?: string | null; -}; diff --git a/app/src/lib/api/models/GenerationResponse.ts b/app/src/lib/api/models/GenerationResponse.ts deleted file mode 100644 index 55599ae58..000000000 --- a/app/src/lib/api/models/GenerationResponse.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Response model for voice generation. - */ -export type GenerationResponse = { - id: string; - profile_id: string; - text: string; - language: string; - audio_path: string; - duration: number; - seed: number | null; - instruct: string | null; - created_at: string; -}; diff --git a/app/src/lib/api/models/HTTPValidationError.ts b/app/src/lib/api/models/HTTPValidationError.ts deleted file mode 100644 index ad8f623bd..000000000 --- a/app/src/lib/api/models/HTTPValidationError.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ValidationError } from './ValidationError'; -export type HTTPValidationError = { - detail?: Array; -}; diff --git a/app/src/lib/api/models/HealthResponse.ts b/app/src/lib/api/models/HealthResponse.ts deleted file mode 100644 index d8aaf9b20..000000000 --- a/app/src/lib/api/models/HealthResponse.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Response model for health check. - */ -export type HealthResponse = { - status: string; - model_loaded: boolean; - model_downloaded?: boolean | null; - model_size?: string | null; - gpu_available: boolean; - vram_used_mb?: number | null; -}; diff --git a/app/src/lib/api/models/HistoryListResponse.ts b/app/src/lib/api/models/HistoryListResponse.ts deleted file mode 100644 index 1bb92c245..000000000 --- a/app/src/lib/api/models/HistoryListResponse.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { HistoryResponse } from './HistoryResponse'; -/** - * Response model for history list. - */ -export type HistoryListResponse = { - items: Array; - total: number; -}; diff --git a/app/src/lib/api/models/HistoryResponse.ts b/app/src/lib/api/models/HistoryResponse.ts deleted file mode 100644 index cc1805ab2..000000000 --- a/app/src/lib/api/models/HistoryResponse.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Response model for history entry (includes profile name). - */ -export type HistoryResponse = { - id: string; - profile_id: string; - profile_name: string; - text: string; - language: string; - audio_path: string; - duration: number; - seed: number | null; - instruct: string | null; - created_at: string; -}; diff --git a/app/src/lib/api/models/ModelDownloadRequest.ts b/app/src/lib/api/models/ModelDownloadRequest.ts deleted file mode 100644 index 8722a24f7..000000000 --- a/app/src/lib/api/models/ModelDownloadRequest.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Request model for triggering model download. - */ -export type ModelDownloadRequest = { - model_name: string; -}; diff --git a/app/src/lib/api/models/ModelStatus.ts b/app/src/lib/api/models/ModelStatus.ts deleted file mode 100644 index fdba42855..000000000 --- a/app/src/lib/api/models/ModelStatus.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Response model for model status. - */ -export type ModelStatus = { - model_name: string; - display_name: string; - downloaded: boolean; - downloading?: boolean; // True if download is in progress - size_mb?: number | null; - loaded?: boolean; -}; diff --git a/app/src/lib/api/models/ModelStatusListResponse.ts b/app/src/lib/api/models/ModelStatusListResponse.ts deleted file mode 100644 index 67ed3f560..000000000 --- a/app/src/lib/api/models/ModelStatusListResponse.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { ModelStatus } from './ModelStatus'; -/** - * Response model for model status list. - */ -export type ModelStatusListResponse = { - models: Array; -}; diff --git a/app/src/lib/api/models/ProfileSampleResponse.ts b/app/src/lib/api/models/ProfileSampleResponse.ts deleted file mode 100644 index 4f95dc786..000000000 --- a/app/src/lib/api/models/ProfileSampleResponse.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Response model for profile sample. - */ -export type ProfileSampleResponse = { - id: string; - profile_id: string; - audio_path: string; - reference_text: string; -}; diff --git a/app/src/lib/api/models/TranscriptionResponse.ts b/app/src/lib/api/models/TranscriptionResponse.ts deleted file mode 100644 index 13b766479..000000000 --- a/app/src/lib/api/models/TranscriptionResponse.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Response model for transcription. - */ -export type TranscriptionResponse = { - text: string; - duration: number; -}; diff --git a/app/src/lib/api/models/ValidationError.ts b/app/src/lib/api/models/ValidationError.ts deleted file mode 100644 index aa015bfd1..000000000 --- a/app/src/lib/api/models/ValidationError.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export type ValidationError = { - loc: Array; - msg: string; - type: string; -}; diff --git a/app/src/lib/api/models/VoiceProfileCreate.ts b/app/src/lib/api/models/VoiceProfileCreate.ts deleted file mode 100644 index 2039ff819..000000000 --- a/app/src/lib/api/models/VoiceProfileCreate.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Request model for creating a voice profile. - */ -export type VoiceProfileCreate = { - name: string; - description?: string | null; - language?: string; -}; diff --git a/app/src/lib/api/models/VoiceProfileResponse.ts b/app/src/lib/api/models/VoiceProfileResponse.ts deleted file mode 100644 index a59830498..000000000 --- a/app/src/lib/api/models/VoiceProfileResponse.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * Response model for voice profile. - */ -export type VoiceProfileResponse = { - id: string; - name: string; - description: string | null; - language: string; - created_at: string; - updated_at: string; -}; diff --git a/app/src/lib/api/schemas/$Body_add_profile_sample_profiles__profile_id__samples_post.ts b/app/src/lib/api/schemas/$Body_add_profile_sample_profiles__profile_id__samples_post.ts deleted file mode 100644 index 42ae7824f..000000000 --- a/app/src/lib/api/schemas/$Body_add_profile_sample_profiles__profile_id__samples_post.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $Body_add_profile_sample_profiles__profile_id__samples_post = { - properties: { - file: { - type: 'binary', - isRequired: true, - format: 'binary', - }, - reference_text: { - type: 'string', - isRequired: true, - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$Body_transcribe_audio_transcribe_post.ts b/app/src/lib/api/schemas/$Body_transcribe_audio_transcribe_post.ts deleted file mode 100644 index 1d692b0f3..000000000 --- a/app/src/lib/api/schemas/$Body_transcribe_audio_transcribe_post.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $Body_transcribe_audio_transcribe_post = { - properties: { - file: { - type: 'binary', - isRequired: true, - format: 'binary', - }, - language: { - type: 'any-of', - contains: [ - { - type: 'string', - }, - { - type: 'null', - }, - ], - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$GenerationRequest.ts b/app/src/lib/api/schemas/$GenerationRequest.ts deleted file mode 100644 index 9f308de8f..000000000 --- a/app/src/lib/api/schemas/$GenerationRequest.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $GenerationRequest = { - description: `Request model for voice generation.`, - properties: { - profile_id: { - type: 'string', - isRequired: true, - }, - text: { - type: 'string', - isRequired: true, - maxLength: 5000, - minLength: 1, - }, - language: { - type: 'string', - pattern: '^(en|zh)$', - }, - seed: { - type: 'any-of', - contains: [ - { - type: 'number', - }, - { - type: 'null', - }, - ], - }, - model_size: { - type: 'any-of', - contains: [ - { - type: 'string', - pattern: '^(1\\.7B|0\\.6B)$', - }, - { - type: 'null', - }, - ], - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$GenerationResponse.ts b/app/src/lib/api/schemas/$GenerationResponse.ts deleted file mode 100644 index dc185ad0b..000000000 --- a/app/src/lib/api/schemas/$GenerationResponse.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $GenerationResponse = { - description: `Response model for voice generation.`, - properties: { - id: { - type: 'string', - isRequired: true, - }, - profile_id: { - type: 'string', - isRequired: true, - }, - text: { - type: 'string', - isRequired: true, - }, - language: { - type: 'string', - isRequired: true, - }, - audio_path: { - type: 'string', - isRequired: true, - }, - duration: { - type: 'number', - isRequired: true, - }, - seed: { - type: 'any-of', - contains: [ - { - type: 'number', - }, - { - type: 'null', - }, - ], - isRequired: true, - }, - created_at: { - type: 'string', - isRequired: true, - format: 'date-time', - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$HTTPValidationError.ts b/app/src/lib/api/schemas/$HTTPValidationError.ts deleted file mode 100644 index 3e0176df4..000000000 --- a/app/src/lib/api/schemas/$HTTPValidationError.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $HTTPValidationError = { - properties: { - detail: { - type: 'array', - contains: { - type: 'ValidationError', - }, - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$HealthResponse.ts b/app/src/lib/api/schemas/$HealthResponse.ts deleted file mode 100644 index c957f0fe3..000000000 --- a/app/src/lib/api/schemas/$HealthResponse.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $HealthResponse = { - description: `Response model for health check.`, - properties: { - status: { - type: 'string', - isRequired: true, - }, - model_loaded: { - type: 'boolean', - isRequired: true, - }, - model_downloaded: { - type: 'any-of', - contains: [ - { - type: 'boolean', - }, - { - type: 'null', - }, - ], - }, - model_size: { - type: 'any-of', - contains: [ - { - type: 'string', - }, - { - type: 'null', - }, - ], - }, - gpu_available: { - type: 'boolean', - isRequired: true, - }, - vram_used_mb: { - type: 'any-of', - contains: [ - { - type: 'number', - }, - { - type: 'null', - }, - ], - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$HistoryListResponse.ts b/app/src/lib/api/schemas/$HistoryListResponse.ts deleted file mode 100644 index 23c4ca15e..000000000 --- a/app/src/lib/api/schemas/$HistoryListResponse.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $HistoryListResponse = { - description: `Response model for history list.`, - properties: { - items: { - type: 'array', - contains: { - type: 'HistoryResponse', - }, - isRequired: true, - }, - total: { - type: 'number', - isRequired: true, - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$HistoryResponse.ts b/app/src/lib/api/schemas/$HistoryResponse.ts deleted file mode 100644 index 2f9ea2849..000000000 --- a/app/src/lib/api/schemas/$HistoryResponse.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $HistoryResponse = { - description: `Response model for history entry (includes profile name).`, - properties: { - id: { - type: 'string', - isRequired: true, - }, - profile_id: { - type: 'string', - isRequired: true, - }, - profile_name: { - type: 'string', - isRequired: true, - }, - text: { - type: 'string', - isRequired: true, - }, - language: { - type: 'string', - isRequired: true, - }, - audio_path: { - type: 'string', - isRequired: true, - }, - duration: { - type: 'number', - isRequired: true, - }, - seed: { - type: 'any-of', - contains: [ - { - type: 'number', - }, - { - type: 'null', - }, - ], - isRequired: true, - }, - created_at: { - type: 'string', - isRequired: true, - format: 'date-time', - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$ModelDownloadRequest.ts b/app/src/lib/api/schemas/$ModelDownloadRequest.ts deleted file mode 100644 index aaabeaab9..000000000 --- a/app/src/lib/api/schemas/$ModelDownloadRequest.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $ModelDownloadRequest = { - description: `Request model for triggering model download.`, - properties: { - model_name: { - type: 'string', - isRequired: true, - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$ModelStatus.ts b/app/src/lib/api/schemas/$ModelStatus.ts deleted file mode 100644 index 765476f78..000000000 --- a/app/src/lib/api/schemas/$ModelStatus.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $ModelStatus = { - description: `Response model for model status.`, - properties: { - model_name: { - type: 'string', - isRequired: true, - }, - display_name: { - type: 'string', - isRequired: true, - }, - downloaded: { - type: 'boolean', - isRequired: true, - }, - size_mb: { - type: 'any-of', - contains: [ - { - type: 'number', - }, - { - type: 'null', - }, - ], - }, - loaded: { - type: 'boolean', - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$ModelStatusListResponse.ts b/app/src/lib/api/schemas/$ModelStatusListResponse.ts deleted file mode 100644 index ddc76d799..000000000 --- a/app/src/lib/api/schemas/$ModelStatusListResponse.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $ModelStatusListResponse = { - description: `Response model for model status list.`, - properties: { - models: { - type: 'array', - contains: { - type: 'ModelStatus', - }, - isRequired: true, - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$ProfileSampleResponse.ts b/app/src/lib/api/schemas/$ProfileSampleResponse.ts deleted file mode 100644 index 5a15e9895..000000000 --- a/app/src/lib/api/schemas/$ProfileSampleResponse.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $ProfileSampleResponse = { - description: `Response model for profile sample.`, - properties: { - id: { - type: 'string', - isRequired: true, - }, - profile_id: { - type: 'string', - isRequired: true, - }, - audio_path: { - type: 'string', - isRequired: true, - }, - reference_text: { - type: 'string', - isRequired: true, - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$TranscriptionResponse.ts b/app/src/lib/api/schemas/$TranscriptionResponse.ts deleted file mode 100644 index a85d524de..000000000 --- a/app/src/lib/api/schemas/$TranscriptionResponse.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $TranscriptionResponse = { - description: `Response model for transcription.`, - properties: { - text: { - type: 'string', - isRequired: true, - }, - duration: { - type: 'number', - isRequired: true, - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$ValidationError.ts b/app/src/lib/api/schemas/$ValidationError.ts deleted file mode 100644 index f3c6906ec..000000000 --- a/app/src/lib/api/schemas/$ValidationError.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $ValidationError = { - properties: { - loc: { - type: 'array', - contains: { - type: 'any-of', - contains: [ - { - type: 'string', - }, - { - type: 'number', - }, - ], - }, - isRequired: true, - }, - msg: { - type: 'string', - isRequired: true, - }, - type: { - type: 'string', - isRequired: true, - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$VoiceProfileCreate.ts b/app/src/lib/api/schemas/$VoiceProfileCreate.ts deleted file mode 100644 index 6b8146e71..000000000 --- a/app/src/lib/api/schemas/$VoiceProfileCreate.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $VoiceProfileCreate = { - description: `Request model for creating a voice profile.`, - properties: { - name: { - type: 'string', - isRequired: true, - maxLength: 100, - minLength: 1, - }, - description: { - type: 'any-of', - contains: [ - { - type: 'string', - maxLength: 500, - }, - { - type: 'null', - }, - ], - }, - language: { - type: 'string', - pattern: '^(en|zh)$', - }, - }, -} as const; diff --git a/app/src/lib/api/schemas/$VoiceProfileResponse.ts b/app/src/lib/api/schemas/$VoiceProfileResponse.ts deleted file mode 100644 index 741cca8df..000000000 --- a/app/src/lib/api/schemas/$VoiceProfileResponse.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -export const $VoiceProfileResponse = { - description: `Response model for voice profile.`, - properties: { - id: { - type: 'string', - isRequired: true, - }, - name: { - type: 'string', - isRequired: true, - }, - description: { - type: 'any-of', - contains: [ - { - type: 'string', - }, - { - type: 'null', - }, - ], - isRequired: true, - }, - language: { - type: 'string', - isRequired: true, - }, - created_at: { - type: 'string', - isRequired: true, - format: 'date-time', - }, - updated_at: { - type: 'string', - isRequired: true, - format: 'date-time', - }, - }, -} as const; diff --git a/app/src/lib/api/services/DefaultService.ts b/app/src/lib/api/services/DefaultService.ts deleted file mode 100644 index 640c8cbce..000000000 --- a/app/src/lib/api/services/DefaultService.ts +++ /dev/null @@ -1,459 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { Body_add_profile_sample_profiles__profile_id__samples_post } from '../models/Body_add_profile_sample_profiles__profile_id__samples_post'; -import type { Body_transcribe_audio_transcribe_post } from '../models/Body_transcribe_audio_transcribe_post'; -import type { GenerationRequest } from '../models/GenerationRequest'; -import type { GenerationResponse } from '../models/GenerationResponse'; -import type { HealthResponse } from '../models/HealthResponse'; -import type { HistoryListResponse } from '../models/HistoryListResponse'; -import type { HistoryResponse } from '../models/HistoryResponse'; -import type { ModelDownloadRequest } from '../models/ModelDownloadRequest'; -import type { ModelStatusListResponse } from '../models/ModelStatusListResponse'; -import type { ProfileSampleResponse } from '../models/ProfileSampleResponse'; -import type { TranscriptionResponse } from '../models/TranscriptionResponse'; -import type { VoiceProfileCreate } from '../models/VoiceProfileCreate'; -import type { VoiceProfileResponse } from '../models/VoiceProfileResponse'; -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class DefaultService { - /** - * Root - * Root endpoint. - * @returns any Successful Response - * @throws ApiError - */ - public static rootGet(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/', - }); - } - /** - * Health - * Health check endpoint. - * @returns HealthResponse Successful Response - * @throws ApiError - */ - public static healthHealthGet(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/health', - }); - } - /** - * List Profiles - * List all voice profiles. - * @returns VoiceProfileResponse Successful Response - * @throws ApiError - */ - public static listProfilesProfilesGet(): CancelablePromise> { - return __request(OpenAPI, { - method: 'GET', - url: '/profiles', - }); - } - /** - * Create Profile - * Create a new voice profile. - * @returns VoiceProfileResponse Successful Response - * @throws ApiError - */ - public static createProfileProfilesPost({ - requestBody, - }: { - requestBody: VoiceProfileCreate; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/profiles', - body: requestBody, - mediaType: 'application/json', - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Get Profile - * Get a voice profile by ID. - * @returns VoiceProfileResponse Successful Response - * @throws ApiError - */ - public static getProfileProfilesProfileIdGet({ - profileId, - }: { - profileId: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/profiles/{profile_id}', - path: { - profile_id: profileId, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Update Profile - * Update a voice profile. - * @returns VoiceProfileResponse Successful Response - * @throws ApiError - */ - public static updateProfileProfilesProfileIdPut({ - profileId, - requestBody, - }: { - profileId: string; - requestBody: VoiceProfileCreate; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/profiles/{profile_id}', - path: { - profile_id: profileId, - }, - body: requestBody, - mediaType: 'application/json', - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Delete Profile - * Delete a voice profile. - * @returns any Successful Response - * @throws ApiError - */ - public static deleteProfileProfilesProfileIdDelete({ - profileId, - }: { - profileId: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/profiles/{profile_id}', - path: { - profile_id: profileId, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Add Profile Sample - * Add a sample to a voice profile. - * @returns ProfileSampleResponse Successful Response - * @throws ApiError - */ - public static addProfileSampleProfilesProfileIdSamplesPost({ - profileId, - formData, - }: { - profileId: string; - formData: Body_add_profile_sample_profiles__profile_id__samples_post; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/profiles/{profile_id}/samples', - path: { - profile_id: profileId, - }, - formData: formData, - mediaType: 'multipart/form-data', - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Get Profile Samples - * Get all samples for a profile. - * @returns ProfileSampleResponse Successful Response - * @throws ApiError - */ - public static getProfileSamplesProfilesProfileIdSamplesGet({ - profileId, - }: { - profileId: string; - }): CancelablePromise> { - return __request(OpenAPI, { - method: 'GET', - url: '/profiles/{profile_id}/samples', - path: { - profile_id: profileId, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Delete Profile Sample - * Delete a profile sample. - * @returns any Successful Response - * @throws ApiError - */ - public static deleteProfileSampleProfilesSamplesSampleIdDelete({ - sampleId, - }: { - sampleId: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/profiles/samples/{sample_id}', - path: { - sample_id: sampleId, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Generate Speech - * Generate speech from text using a voice profile. - * @returns GenerationResponse Successful Response - * @throws ApiError - */ - public static generateSpeechGeneratePost({ - requestBody, - }: { - requestBody: GenerationRequest; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/generate', - body: requestBody, - mediaType: 'application/json', - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * List History - * List generation history with optional filters. - * @returns HistoryListResponse Successful Response - * @throws ApiError - */ - public static listHistoryHistoryGet({ - profileId, - search, - limit = 50, - offset, - }: { - profileId?: string | null; - search?: string | null; - limit?: number; - offset?: number; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/history', - query: { - profile_id: profileId, - search: search, - limit: limit, - offset: offset, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Get Generation - * Get a generation by ID. - * @returns HistoryResponse Successful Response - * @throws ApiError - */ - public static getGenerationHistoryGenerationIdGet({ - generationId, - }: { - generationId: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/history/{generation_id}', - path: { - generation_id: generationId, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Delete Generation - * Delete a generation. - * @returns any Successful Response - * @throws ApiError - */ - public static deleteGenerationHistoryGenerationIdDelete({ - generationId, - }: { - generationId: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/history/{generation_id}', - path: { - generation_id: generationId, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Get Stats - * Get generation statistics. - * @returns any Successful Response - * @throws ApiError - */ - public static getStatsHistoryStatsGet(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/history/stats', - }); - } - /** - * Transcribe Audio - * Transcribe audio file to text. - * @returns TranscriptionResponse Successful Response - * @throws ApiError - */ - public static transcribeAudioTranscribePost({ - formData, - }: { - formData: Body_transcribe_audio_transcribe_post; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/transcribe', - formData: formData, - mediaType: 'multipart/form-data', - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Get Audio - * Serve generated audio file. - * @returns any Successful Response - * @throws ApiError - */ - public static getAudioAudioGenerationIdGet({ - generationId, - }: { - generationId: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/audio/{generation_id}', - path: { - generation_id: generationId, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Load Model - * Manually load TTS model. - * @returns any Successful Response - * @throws ApiError - */ - public static loadModelModelsLoadPost({ - modelSize = '1.7B', - }: { - modelSize?: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/models/load', - query: { - model_size: modelSize, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Unload Model - * Unload TTS model to free memory. - * @returns any Successful Response - * @throws ApiError - */ - public static unloadModelModelsUnloadPost(): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/models/unload', - }); - } - /** - * Get Model Progress - * Get model download progress via Server-Sent Events. - * @returns any Successful Response - * @throws ApiError - */ - public static getModelProgressModelsProgressModelNameGet({ - modelName, - }: { - modelName: string; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/models/progress/{model_name}', - path: { - model_name: modelName, - }, - errors: { - 422: `Validation Error`, - }, - }); - } - /** - * Get Model Status - * Get status of all available models. - * @returns ModelStatusListResponse Successful Response - * @throws ApiError - */ - public static getModelStatusModelsStatusGet(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/models/status', - }); - } - /** - * Trigger Model Download - * Trigger download of a specific model. - * @returns any Successful Response - * @throws ApiError - */ - public static triggerModelDownloadModelsDownloadPost({ - requestBody, - }: { - requestBody: ModelDownloadRequest; - }): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/models/download', - body: requestBody, - mediaType: 'application/json', - errors: { - 422: `Validation Error`, - }, - }); - } -} diff --git a/app/src/main.tsx b/app/src/main.tsx deleted file mode 100644 index 52dc90445..000000000 --- a/app/src/main.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { QueryClientProvider } from '@tanstack/react-query'; -// import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './i18n'; -import './index.css'; -import { queryClient } from './lib/queryClient'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - {/* */} - - , -); diff --git a/app/src/platform/types.ts b/app/src/platform/types.ts index 2e11af9a1..ef1203587 100644 --- a/app/src/platform/types.ts +++ b/app/src/platform/types.ts @@ -9,7 +9,8 @@ export interface FileFilter { } export interface PlatformFilesystem { - saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise; + /** Returns the saved path (or filename on web), or null if the user cancelled. */ + saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise; openPath(path: string): Promise; pickDirectory(title: string): Promise; } diff --git a/app/src/router.tsx b/app/src/router.tsx index 940e7c525..13ba45d5f 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -113,7 +113,7 @@ const voicesRoute = createRoute({ component: VoicesTab, }); -// Captures route (prototype — will replace AudioTab once the new flow is ready) +// Captures route const capturesRoute = createRoute({ getParentRoute: () => rootRoute, path: '/captures', @@ -199,8 +199,8 @@ const serverRedirectRoute = createRoute({ }, }); -// Route tree -const routeTree = rootRoute.addChildren([ +// Route tree — exported so tests can build routers over memory history +export const routeTree = rootRoute.addChildren([ indexRoute, storiesRoute, capturesRoute, diff --git a/app/src/stores/serverStore.test.ts b/app/src/stores/serverStore.test.ts new file mode 100644 index 000000000..78a2f70d4 --- /dev/null +++ b/app/src/stores/serverStore.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { queryClient } from '@/lib/queryClient'; +import { isLoopbackVoiceboxServerUrl, useServerStore } from '@/stores/serverStore'; + +describe('serverStore', () => { + it('invalidates all queries when the server url changes', () => { + const spy = vi.spyOn(queryClient, 'invalidateQueries'); + + useServerStore.getState().setServerUrl('http://10.0.0.5:17493'); + + expect(useServerStore.getState().serverUrl).toBe('http://10.0.0.5:17493'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('does not invalidate queries when the url is unchanged', () => { + const url = useServerStore.getState().serverUrl; + const spy = vi.spyOn(queryClient, 'invalidateQueries'); + + useServerStore.getState().setServerUrl(url); + + expect(spy).not.toHaveBeenCalled(); + }); +}); + +describe('isLoopbackVoiceboxServerUrl', () => { + it('matches loopback hosts on the voicebox port', () => { + expect(isLoopbackVoiceboxServerUrl('http://127.0.0.1:17493')).toBe(true); + expect(isLoopbackVoiceboxServerUrl('http://localhost:17493')).toBe(true); + expect(isLoopbackVoiceboxServerUrl('http://[::1]:17493')).toBe(true); + }); + + it('rejects other hosts, ports, and junk', () => { + expect(isLoopbackVoiceboxServerUrl('http://10.0.0.5:17493')).toBe(false); + expect(isLoopbackVoiceboxServerUrl('http://127.0.0.1:8000')).toBe(false); + expect(isLoopbackVoiceboxServerUrl('not a url')).toBe(false); + }); +}); diff --git a/app/src/stores/uiStore.test.ts b/app/src/stores/uiStore.test.ts new file mode 100644 index 000000000..d93eccbc8 --- /dev/null +++ b/app/src/stores/uiStore.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { useUIStore } from '@/stores/uiStore'; + +describe('uiStore', () => { + it('applies the dark class when theme is set to dark', () => { + useUIStore.getState().setTheme('dark'); + + expect(useUIStore.getState().theme).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('removes the dark class when theme is set to light', () => { + useUIStore.getState().setTheme('dark'); + useUIStore.getState().setTheme('light'); + + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + it('persists only theme and selectedProfileId', () => { + useUIStore.getState().setTheme('light'); + useUIStore.getState().setSidebarOpen(false); + useUIStore.getState().setSelectedEngine('kokoro'); + + const persisted = JSON.parse(localStorage.getItem('voicebox-ui') ?? '{}'); + expect(persisted.state).toEqual({ selectedProfileId: null, theme: 'light' }); + }); +}); diff --git a/app/src/test/harness.browser.test.tsx b/app/src/test/harness.browser.test.tsx new file mode 100644 index 000000000..77af25e1e --- /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/mockPlatform.ts b/app/src/test/mockPlatform.ts new file mode 100644 index 000000000..10ec79a3a --- /dev/null +++ b/app/src/test/mockPlatform.ts @@ -0,0 +1,86 @@ +import { vi } from 'vitest'; +import type { Platform, UpdateStatus } from '@/platform/types'; + +export interface MockPlatform extends Platform { + /** Push a new updater status to all subscribers, as the real updater would. */ + emitUpdateStatus(status: UpdateStatus): void; +} + +export interface MockPlatformOverrides { + filesystem?: Partial; + updater?: Partial; + audio?: Partial; + lifecycle?: Partial; + metadata?: Partial; +} + +const INITIAL_UPDATE_STATUS: UpdateStatus = { + checking: false, + available: false, + downloading: false, + installing: false, + readyToInstall: false, +}; + +export const TEST_SERVER_URL = 'http://127.0.0.1:17493'; + +/** + * A fully spy-able Platform. Every method is a vi.fn with a benign default + * (browser-like: no system audio, isTauri false), so tests can assert calls + * or override behavior per section via `overrides`. + */ +export function createMockPlatform(overrides: MockPlatformOverrides = {}): MockPlatform { + let updateStatus = { ...INITIAL_UPDATE_STATUS }; + const subscribers = new Set<(status: UpdateStatus) => void>(); + + return { + filesystem: { + saveFile: vi.fn(async (filename: string) => filename), + openPath: vi.fn(async () => {}), + pickDirectory: vi.fn(async () => null), + ...overrides.filesystem, + }, + updater: { + checkForUpdates: vi.fn(async () => {}), + downloadAndInstall: vi.fn(async () => {}), + restartAndInstall: vi.fn(async () => {}), + getStatus: vi.fn(() => ({ ...updateStatus })), + subscribe: vi.fn((callback: (status: UpdateStatus) => void) => { + subscribers.add(callback); + callback(updateStatus); + return () => { + subscribers.delete(callback); + }; + }), + ...overrides.updater, + }, + audio: { + isSystemAudioSupported: vi.fn(async () => false), + startSystemAudioCapture: vi.fn(async () => {}), + stopSystemAudioCapture: vi.fn(async () => new Blob()), + listOutputDevices: vi.fn(async () => []), + playToDevices: vi.fn(async () => {}), + stopPlayback: vi.fn(), + ...overrides.audio, + }, + lifecycle: { + startServer: vi.fn(async () => TEST_SERVER_URL), + stopServer: vi.fn(async () => {}), + restartServer: vi.fn(async () => TEST_SERVER_URL), + setKeepServerRunning: vi.fn(async () => {}), + setBackendOverride: vi.fn(async () => {}), + setupWindowCloseHandler: vi.fn(async () => {}), + subscribeToServerLogs: vi.fn(() => () => {}), + ...overrides.lifecycle, + }, + metadata: { + getVersion: vi.fn(async () => '0.0.0-test'), + isTauri: false, + ...overrides.metadata, + }, + emitUpdateStatus(status: UpdateStatus) { + updateStatus = { ...status }; + for (const callback of subscribers) callback(updateStatus); + }, + }; +} diff --git a/app/src/test/msw/fixtures.ts b/app/src/test/msw/fixtures.ts new file mode 100644 index 000000000..36bb15be1 --- /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 000000000..794833843 --- /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/msw/handlers/server.ts b/app/src/test/msw/handlers/server.ts new file mode 100644 index 000000000..0b35fd60e --- /dev/null +++ b/app/src/test/msw/handlers/server.ts @@ -0,0 +1,17 @@ +import { type HttpHandler, HttpResponse, http } from 'msw'; + +/** + * Baseline handlers for endpoints nearly every screen touches. The health + * payload mirrors backend/routes/health.py closely enough for the UI's + * checks (`status`, `model_loaded`, backend variant fields). + */ +export const serverHandlers: HttpHandler[] = [ + http.get('*/health', () => + HttpResponse.json({ + status: 'ok', + model_loaded: false, + device: 'cpu', + backend_variant: 'cpu', + }), + ), +]; diff --git a/app/src/test/msw/worker.ts b/app/src/test/msw/worker.ts new file mode 100644 index 000000000..b99217003 --- /dev/null +++ b/app/src/test/msw/worker.ts @@ -0,0 +1,9 @@ +import { setupWorker } from 'msw/browser'; +import { serverHandlers } from './handlers/server'; + +/** + * Browser-mode MSW worker. Individual tests layer route-specific handlers + * on top with `worker.use(...)`; `setup.browser.ts` resets them after each + * test. Only the health/baseline handlers are registered globally. + */ +export const worker = setupWorker(...serverHandlers); diff --git a/app/src/test/public/mockServiceWorker.js b/app/src/test/public/mockServiceWorker.js new file mode 100644 index 000000000..c646a2362 --- /dev/null +++ b/app/src/test/public/mockServiceWorker.js @@ -0,0 +1,346 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0'; +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +addEventListener('install', () => { + self.skipWaiting(); +}); + +addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +addEventListener('message', async (event) => { + const clientId = Reflect.get(event.source || {}, 'id'); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener('fetch', (event) => { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse(event, client, requestId, requestInterceptedAt); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream'); + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept'); + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()); + const filteredValues = values.filter((value) => value !== 'msw/passthrough'); + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')); + } else { + headers.delete('accept'); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/app/src/test/render.tsx b/app/src/test/render.tsx new file mode 100644 index 000000000..b7b41ad45 --- /dev/null +++ b/app/src/test/render.tsx @@ -0,0 +1,71 @@ +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 { + return new QueryClient({ + defaultOptions: { + // Retries and interval refetching are disabled so tests are + // deterministic — polling components get their data exactly once. + queries: { + retry: false, + refetchInterval: false, + refetchOnWindowFocus: false, + gcTime: Number.POSITIVE_INFINITY, + }, + mutations: { retry: false }, + }, + }); +} + +export interface RenderWithProvidersOptions { + platform?: MockPlatform; + 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( + + {ui} + , + ); + + // Object.assign keeps the render result's prototype methods (locators) + // 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/resetStores.ts b/app/src/test/resetStores.ts new file mode 100644 index 000000000..adf90a36d --- /dev/null +++ b/app/src/test/resetStores.ts @@ -0,0 +1,36 @@ +import { queryClient } from '@/lib/queryClient'; +import { useAudioChannelStore } from '@/stores/audioChannelStore'; +import { useEffectsStore } from '@/stores/effectsStore'; +import { useGenerationStore } from '@/stores/generationStore'; +import { useLogStore } from '@/stores/logStore'; +import { usePlayerStore } from '@/stores/playerStore'; +import { useServerStore } from '@/stores/serverStore'; +import { useStoryStore } from '@/stores/storyStore'; +import { useUIStore } from '@/stores/uiStore'; + +const stores = [ + useAudioChannelStore, + useEffectsStore, + useGenerationStore, + useLogStore, + usePlayerStore, + useServerStore, + useStoryStore, + useUIStore, +] as const; + +// Snapshot pristine state at module load, before any test mutates anything. +const snapshots = stores.map((store) => store.getState()); + +/** + * Restore every zustand store to its initial state and clear persisted + * copies so tests can't leak state into each other. Persisted stores write + * through to localStorage on setState, so localStorage is cleared last. + */ +export function resetAllStores(): void { + stores.forEach((store, i) => { + store.setState(snapshots[i] as never, true); + }); + queryClient.clear(); + localStorage.clear(); +} diff --git a/app/src/test/setup.browser.ts b/app/src/test/setup.browser.ts new file mode 100644 index 000000000..e0b6d1d13 --- /dev/null +++ b/app/src/test/setup.browser.ts @@ -0,0 +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(); +}); + +// 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/setup.ts b/app/src/test/setup.ts new file mode 100644 index 000000000..231b5c958 --- /dev/null +++ b/app/src/test/setup.ts @@ -0,0 +1,8 @@ +import '@/i18n'; +import { afterEach, vi } from 'vitest'; +import { resetAllStores } from './resetStores'; + +afterEach(() => { + vi.restoreAllMocks(); + resetAllStores(); +}); diff --git a/app/src/test/sse.ts b/app/src/test/sse.ts new file mode 100644 index 000000000..ec6c88219 --- /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/app/vite.config.ts b/app/vite.config.ts deleted file mode 100644 index 36bc168bf..000000000 --- a/app/vite.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import path from 'node:path'; -import tailwindcss from '@tailwindcss/vite'; -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; -import { changelogPlugin } from './plugins/changelog'; - -export default defineConfig({ - plugins: [tailwindcss(), react(), changelogPlugin(path.resolve(__dirname, '..'))], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, - }, -}); diff --git a/backend/backends/__init__.py b/backend/backends/__init__.py index abaf70c17..c7870268d 100644 --- a/backend/backends/__init__.py +++ b/backend/backends/__init__.py @@ -12,6 +12,7 @@ # HF_HUB_OFFLINE=1 and on network failures. from ..utils import hf_offline_patch # noqa: F401 +import os import threading from dataclasses import dataclass, field from typing import Protocol, Optional, Tuple, List @@ -678,6 +679,13 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend: """ global _tts_backends + # Test mode: every engine resolves to the fake backend so the full + # generation pipeline runs without model weights (see fake_backend.py). + if os.environ.get("VOICEBOX_FAKE_TTS") == "1": + from .fake_backend import get_fake_backend + + return get_fake_backend() + # Fast path: check without lock if engine in _tts_backends: return _tts_backends[engine] diff --git a/backend/backends/fake_backend.py b/backend/backends/fake_backend.py new file mode 100644 index 000000000..cb6b3b2c6 --- /dev/null +++ b/backend/backends/fake_backend.py @@ -0,0 +1,92 @@ +"""Fake TTS backend for UI and E2E testing. + +Activated by ``VOICEBOX_FAKE_TTS=1``. Every engine resolves to this backend, +which synthesizes a quiet sine tone sized to the input text — so the full +generation pipeline (task queue, SSE progress, database rows, audio serving) +runs exactly as in production, minus model weights and GPU time. +""" + +import asyncio +import logging +from typing import ClassVar, Optional + +import numpy as np + +logger = logging.getLogger(__name__) + +SAMPLE_RATE = 24_000 +SECONDS_PER_CHAR = 0.02 +MIN_DURATION_S = 0.25 +TONE_HZ = 440.0 +AMPLITUDE = 0.1 + + +class FakeTTSBackend: + """Implements the TTSBackend protocol without any model.""" + + MODEL_CONFIGS: ClassVar[list] = [] + + def __init__(self) -> None: + self._loaded = False + + async def load_model(self, model_size: str = "default") -> None: + if self._loaded: + return + # Brief pause so the UI's loading_model state is observable. + await asyncio.sleep(0.1) + self._loaded = True + logger.info("Fake TTS backend loaded (VOICEBOX_FAKE_TTS)") + + async def load_model_async(self, model_size: str = "default") -> None: + # Qwen engines are loaded through this variant (see load_engine_model). + await self.load_model(model_size) + + async def create_voice_prompt( + self, + audio_path: str, + reference_text: str, + use_cache: bool = True, + ) -> tuple[dict, bool]: + return ({"fake": True, "audio_path": audio_path, "reference_text": reference_text}, False) + + async def combine_voice_prompts( + self, + audio_paths: list[str], + reference_texts: list[str], + ) -> tuple[np.ndarray, str]: + combined_text = " ".join(reference_texts) + return np.zeros(SAMPLE_RATE, dtype=np.float32), combined_text + + async def generate( + self, + text: str, + voice_prompt: dict, + language: str = "en", + seed: Optional[int] = None, + instruct: Optional[str] = None, + ) -> tuple[np.ndarray, int]: + duration_s = max(MIN_DURATION_S, len(text) * SECONDS_PER_CHAR) + # Yield once so cancellation has a window, mirroring real inference. + await asyncio.sleep(0.05) + t = np.linspace(0.0, duration_s, int(SAMPLE_RATE * duration_s), endpoint=False) + audio = (AMPLITUDE * np.sin(2.0 * np.pi * TONE_HZ * t)).astype(np.float32) + return audio, SAMPLE_RATE + + def unload_model(self) -> None: + self._loaded = False + + def is_loaded(self) -> bool: + return self._loaded + + def _get_model_path(self, model_size: str) -> str: + return "fake" + + +_fake_backend: Optional[FakeTTSBackend] = None + + +def get_fake_backend() -> FakeTTSBackend: + global _fake_backend + if _fake_backend is None: + _fake_backend = FakeTTSBackend() + return _fake_backend diff --git a/backend/requirements-ci.txt b/backend/requirements-ci.txt new file mode 100644 index 000000000..8e328d349 --- /dev/null +++ b/backend/requirements-ci.txt @@ -0,0 +1,25 @@ +# Minimal dependency set to boot the backend on a CPU-only CI runner. +# No TTS/STT model libraries — inference is covered by the fake TTS +# backend (VOICEBOX_FAKE_TTS=1). Install CPU torch first on Linux: +# pip install torch --index-url https://download.pytorch.org/whl/cpu +# then: pip install -r backend/requirements-ci.txt + +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.5.0 +sqlalchemy>=2.0.0 +alembic>=1.13.0 +torch>=2.2.0 +huggingface_hub>=0.20.0 +numpy +soundfile +python-multipart +sse-starlette +psutil +requests +httpx +fastmcp +librosa +pillow +pydub +pedalboard diff --git a/bun.lock b/bun.lock index e9491131c..c72f6b8ef 100644 --- a/bun.lock +++ b/bun.lock @@ -10,9 +10,18 @@ }, "devDependencies": { "@biomejs/biome": "2.3.12", + "@playwright/test": "^1.62.1", "@types/node": "^20.0.0", + "@vitejs/plugin-react": "^6.0.5", + "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", + "happy-dom": "^20.11.2", + "msw": "^2.15.0", + "playwright": "^1.62.1", "tailwindcss": "^4.1.18", "typescript": "^5.6.0", + "vitest": "^4.1.10", + "vitest-browser-react": "^2.2.0", }, }, "app": { @@ -180,6 +189,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="], + "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -258,6 +269,16 @@ "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], + + "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], + + "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], + + "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -268,12 +289,26 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@3.0.0", "", {}, "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA=="], + + "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], + + "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + + "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], + + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -358,7 +393,35 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="], @@ -410,6 +473,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -502,6 +567,10 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], @@ -512,6 +581,14 @@ "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="], + + "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@7.18.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@7.18.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", "@typescript-eslint/typescript-estree": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg=="], @@ -530,7 +607,25 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="], + + "@vitest/browser": ["@vitest/browser@4.1.10", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.10" } }, "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng=="], + + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.10", "", { "dependencies": { "@vitest/browser": "4.1.10", "@vitest/mocker": "4.1.10", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.10" } }, "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw=="], + + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], "@voicebox/app": ["@voicebox/app@workspace:app"], @@ -554,6 +649,8 @@ "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="], @@ -564,16 +661,24 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -584,6 +689,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -606,8 +713,14 @@ "electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -632,8 +745,12 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -642,8 +759,16 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -658,10 +783,12 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -676,8 +803,14 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], + + "happy-dom": ["happy-dom@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], "i18next": ["i18next@26.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="], @@ -696,8 +829,12 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], @@ -776,8 +913,14 @@ "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "msw": ["msw@2.15.0", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ=="], + + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -786,10 +929,14 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -802,11 +949,21 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -840,12 +997,18 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="], + "rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -858,22 +1021,42 @@ "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "sound-visualizer": ["sound-visualizer@1.4.0", "", {}, "sha512-2+Un0PrrBgXylnCjrVYUoRW7KEDH29h7O8/MGzeDOgFGBPb9oX/2n/RGBxJXvVv2U3KFwX5olUWeJKf0Rr5TLQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -888,20 +1071,38 @@ "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + + "tldts": ["tldts@7.4.10", "", { "dependencies": { "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog=="], + + "tldts-core": ["tldts-core@7.4.10", "", {}, "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + "ts-api-utils": ["ts-api-utils@1.4.3", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -914,24 +1115,44 @@ "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + + "vitest-browser-react": ["vitest-browser-react@2.2.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], "wavesurfer.js": ["wavesurfer.js@7.12.2", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], @@ -974,16 +1195,100 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@vitejs/plugin-react/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], + + "@voicebox/app/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@voicebox/tauri/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "@voicebox/web/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@voicebox/web/wavesurfer.js": ["wavesurfer.js@7.12.1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "motion/framer-motion": ["framer-motion@12.29.0", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@vitejs/plugin-react/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@vitejs/plugin-react/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "@vitejs/plugin-react/vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "@voicebox/app/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@voicebox/tauri/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@voicebox/web/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "motion/framer-motion/motion-dom": ["motion-dom@12.29.0", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="], "motion/framer-motion/motion-utils": ["motion-utils@12.27.2", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="], + + "vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "vitest/vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "@vitejs/plugin-react/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "@vitejs/plugin-react/vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "vitest/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "vitest/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "vitest/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "vitest/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "vitest/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "vitest/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "vitest/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "vitest/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "vitest/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "vitest/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "vitest/vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], } } diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 000000000..2210a79e0 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,99 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test as base } from '@playwright/test'; + +const REPO_ROOT = path.resolve(__dirname, '..'); +const BASE_PORT = 18100; + +export interface BackendFixture { + url: string; + dataDir: string; +} + +async function waitForHealth(url: string, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${url}/health`); + if (res.ok) return; + } catch { + // not up yet + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`backend at ${url} did not become healthy within ${timeoutMs}ms`); +} + +export const test = base.extend, { backend: BackendFixture }>({ + backend: [ + async ({}, use, workerInfo) => { + const port = BASE_PORT + workerInfo.workerIndex; + const url = `http://127.0.0.1:${port}`; + // uvicorn resolves the data dir from cwd, so a temp cwd isolates + // each worker's SQLite and audio files completely. + const dataDir = mkdtempSync(path.join(tmpdir(), `voicebox-e2e-${workerInfo.workerIndex}-`)); + const python = process.env.VOICEBOX_PYTHON ?? path.join(REPO_ROOT, '.venv-ci/bin/python'); + + const proc: ChildProcess = spawn( + python, + ['-m', 'uvicorn', 'backend.main:app', '--port', String(port), '--log-level', 'warning'], + { + cwd: dataDir, + env: { + ...process.env, + PYTHONPATH: REPO_ROOT, + VOICEBOX_FAKE_TTS: '1', + VOICEBOX_CORS_ORIGINS: + 'http://localhost:4173,http://127.0.0.1:4173,http://localhost:5173', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const logs: Buffer[] = []; + proc.stdout?.on('data', (chunk) => logs.push(chunk)); + proc.stderr?.on('data', (chunk) => logs.push(chunk)); + + try { + await waitForHealth(url); + } catch (error) { + proc.kill('SIGKILL'); + throw new Error(`${(error as Error).message}\nbackend log:\n${Buffer.concat(logs)}`); + } + + await use({ url, dataDir }); + + proc.kill('SIGTERM'); + await new Promise((resolve) => { + proc.once('exit', resolve); + setTimeout(resolve, 5000); + }); + rmSync(dataDir, { recursive: true, force: true }); + }, + { scope: 'worker' }, + ], + + // Point the app's persisted server store at this worker's backend before + // any page script runs. + page: async ({ page, backend }, use) => { + await page.addInitScript((serverUrl) => { + window.localStorage.setItem( + 'voicebox-server', + JSON.stringify({ + state: { + serverUrl, + isConnected: false, + mode: 'local', + keepServerRunningOnClose: false, + customModelsDir: null, + }, + version: 0, + }), + ); + }, backend.url); + await use(page); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/helpers/api.ts b/e2e/helpers/api.ts new file mode 100644 index 000000000..a3e3d26d0 --- /dev/null +++ b/e2e/helpers/api.ts @@ -0,0 +1,58 @@ +/** Direct REST seeding against the per-worker backend — faster and less + * brittle than driving every prerequisite through the UI. */ + +export interface SeededProfile { + id: string; + name: string; +} + +/** A 3-second 220 Hz sine WAV (backend requires samples >= 2s). */ +export function buildSampleWav(): Blob { + const sampleRate = 24_000; + const seconds = 3; + const samples = sampleRate * seconds; + const dataSize = samples * 2; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + const writeString = (offset: number, s: string) => { + for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); + }; + writeString(0, 'RIFF'); + view.setUint32(4, 36 + dataSize, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + writeString(36, 'data'); + view.setUint32(40, dataSize, true); + for (let i = 0; i < samples; i++) { + view.setInt16(44 + i * 2, Math.round(0.1 * 32767 * Math.sin((2 * Math.PI * 220 * i) / sampleRate)), true); + } + return new Blob([buffer], { type: 'audio/wav' }); +} + +export async function seedProfile(backendUrl: string, name: string): Promise { + const createRes = await fetch(`${backendUrl}/profiles`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, language: 'en' }), + }); + if (!createRes.ok) throw new Error(`create profile failed: ${createRes.status}`); + const profile = (await createRes.json()) as { id: string }; + + const form = new FormData(); + form.append('file', buildSampleWav(), 'sample.wav'); + form.append('reference_text', 'hello world this is a reference sample'); + const sampleRes = await fetch(`${backendUrl}/profiles/${profile.id}/samples`, { + method: 'POST', + body: form, + }); + if (!sampleRes.ok) throw new Error(`add sample failed: ${sampleRes.status} ${await sampleRes.text()}`); + + return { id: profile.id, name }; +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..737f0a90c --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * E2E suite driving the web build (same app as Tauri, browser platform) + * against a real CPU backend with the fake TTS engine. Each worker gets + * its own uvicorn on its own port with its own data dir — see fixtures.ts. + * + * PW_DEV=1 targets `bun run dev:web` (port 5173) instead of the preview + * build for faster local iteration. + */ +const DEV = !!process.env.PW_DEV; +const PORT = DEV ? 5173 : 4173; + +export default defineConfig({ + testDir: './specs', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list', + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'on-first-retry', + video: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: DEV + ? 'bun run dev:web' + : 'bun run build:web && cd web && bunx vite preview --port 4173 --strictPort', + cwd: '..', + port: PORT, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/e2e/specs/generate.spec.ts b/e2e/specs/generate.spec.ts new file mode 100644 index 000000000..e59dbd974 --- /dev/null +++ b/e2e/specs/generate.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '../fixtures'; +import { seedProfile } from '../helpers/api'; + +test('generate speech end to end through the fake TTS pipeline', async ({ page, backend }) => { + const profile = await seedProfile(backend.url, 'Narrator'); + + await page.goto('/'); + + // Select the seeded voice, type into the generate box, and submit. + await page.getByText(profile.name).first().click(); + const input = page.getByRole('textbox').first(); + await input.click(); + await page.keyboard.type('The quick brown fox jumps over the lazy dog.'); + await page.getByRole('button', { name: 'Generate speech' }).click(); + + // The row lands in history and completes via the real queue + SSE. + await expect(page.getByText('The quick brown fox', { exact: false }).first()).toBeVisible({ + timeout: 15_000, + }); + + await expect + .poll( + async () => { + const res = await fetch(`${backend.url}/history?limit=10`); + const body = (await res.json()) as { items: { status: string }[] }; + return body.items[0]?.status; + }, + { timeout: 20_000 }, + ) + .toBe('completed'); +}); diff --git a/e2e/specs/startup.spec.ts b/e2e/specs/startup.spec.ts new file mode 100644 index 000000000..fd5c9722a --- /dev/null +++ b/e2e/specs/startup.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '../fixtures'; + +test('app boots against the backend and shows the main editor', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Voicebox' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Create Voice' }).first()).toBeVisible(); +}); + +test('settings layout renders its pages', async ({ page }) => { + await page.goto('/settings/about'); + + await expect(page.getByText('General', { exact: true })).toBeVisible(); + await expect(page.getByText('Changelog', { exact: true })).toBeVisible(); + await expect(page.getByText('About', { exact: true })).toBeVisible(); +}); diff --git a/e2e/specs/voices.spec.ts b/e2e/specs/voices.spec.ts new file mode 100644 index 000000000..92e7cf7c9 --- /dev/null +++ b/e2e/specs/voices.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from '../fixtures'; +import { seedProfile } from '../helpers/api'; + +test('a seeded voice profile appears in the voices tab', async ({ page, backend }) => { + const profile = await seedProfile(backend.url, 'Marcus Aurelius'); + + await page.goto('/voices'); + + await expect(page.getByText(profile.name).first()).toBeVisible(); +}); diff --git a/package.json b/package.json index b1e5682dd..5e1120b14 100644 --- a/package.json +++ b/package.json @@ -27,13 +27,24 @@ "format:check": "biome format .", "check": "biome check .", "check:fix": "biome check --write .", + "test": "vitest run", + "test:watch": "vitest", "ci": "bun run typecheck && bun run build:web" }, "devDependencies": { "@biomejs/biome": "2.3.12", + "@playwright/test": "^1.62.1", "@types/node": "^20.0.0", + "@vitejs/plugin-react": "^6.0.5", + "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", + "happy-dom": "^20.11.2", + "msw": "^2.15.0", + "playwright": "^1.62.1", "tailwindcss": "^4.1.18", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^4.1.10", + "vitest-browser-react": "^2.2.0" }, "engines": { "bun": ">=1.0.0" diff --git a/tauri/src/main.tsx b/tauri/src/main.tsx index 398fc57e8..5c1eadb9c 100644 --- a/tauri/src/main.tsx +++ b/tauri/src/main.tsx @@ -1,24 +1,15 @@ +import { QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import App from '@/App'; // Import CSS from app directory using alias so Tailwind can scan the source files import '@/index.css'; +import '@/i18n'; +import { queryClient } from '@/lib/queryClient'; import { PlatformProvider } from '@/platform/PlatformContext'; import { tauriPlatform } from './platform'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1000 * 60 * 5, // 5 minutes - gcTime: 1000 * 60 * 10, // 10 minutes - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); - ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/tauri/src/platform/filesystem.ts b/tauri/src/platform/filesystem.ts index 1d8ded39d..4da11ab37 100644 --- a/tauri/src/platform/filesystem.ts +++ b/tauri/src/platform/filesystem.ts @@ -10,7 +10,7 @@ export const tauriFilesystem: PlatformFilesystem = { filters: filters || [], }); - if (!filePath) return; // User cancelled the dialog + if (!filePath) return null; // User cancelled the dialog const resolvedPath = typeof filePath === 'string' ? filePath : (filePath as { path: string }).path; @@ -21,6 +21,7 @@ export const tauriFilesystem: PlatformFilesystem = { const arrayBuffer = await blob.arrayBuffer(); await writeFile(resolvedPath, new Uint8Array(arrayBuffer)); + return resolvedPath; }, async openPath(path: string) { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..bee089548 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,80 @@ +import path from 'node:path'; +import react from '@vitejs/plugin-react'; +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; +import { changelogPlugin } from './app/plugins/changelog'; + +const appSrc = path.resolve(__dirname, 'app/src'); + +const shared = { + plugins: [react(), changelogPlugin(__dirname)], + resolve: { + alias: { '@': appSrc }, + }, +}; + +export default defineConfig({ + ...shared, + test: { + projects: [ + { + ...shared, + test: { + name: 'unit', + environment: 'happy-dom', + include: ['app/src/**/*.test.{ts,tsx}'], + exclude: ['app/src/**/*.browser.test.{ts,tsx}'], + setupFiles: ['app/src/test/setup.ts'], + }, + }, + { + ...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}'], + setupFiles: ['app/src/test/setup.ts', 'app/src/test/setup.browser.ts'], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], + }, +}); diff --git a/web/src/main.tsx b/web/src/main.tsx index 771f07e84..5a344f194 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,22 +1,13 @@ +import { QueryClientProvider } from '@tanstack/react-query'; import React from 'react'; import ReactDOM from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from '../../app/src/App'; import '../../app/src/index.css'; +import '../../app/src/i18n'; +import { queryClient } from '../../app/src/lib/queryClient'; import { PlatformProvider } from '../../app/src/platform/PlatformContext'; import { webPlatform } from './platform'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 1000 * 60 * 5, // 5 minutes - gcTime: 1000 * 60 * 10, // 10 minutes - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); - ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/web/src/platform/filesystem.ts b/web/src/platform/filesystem.ts index 3a871ad20..a15441e82 100644 --- a/web/src/platform/filesystem.ts +++ b/web/src/platform/filesystem.ts @@ -11,6 +11,7 @@ export const webFilesystem: PlatformFilesystem = { a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); + return filename; }, async openPath(_path: string) {