From 46cf5803a3b676b238032dc8db8e8d1a4f48f0bc Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:15:14 -0700 Subject: [PATCH 01/10] fix: route CapturesTab exports through the platform layer The wav/txt/md export handlers called plugin-dialog and plugin-fs directly, which breaks in the web build. saveFile now returns the saved path (null on cancel) so callers can toast accurately, and the cross-window capture listeners only register under Tauri. --- .../components/CapturesTab/CapturesTab.tsx | 204 ++++++++++-------- app/src/platform/types.ts | 3 +- tauri/src/platform/filesystem.ts | 3 +- web/src/platform/filesystem.ts | 1 + 4 files changed, 115 insertions(+), 96 deletions(-) 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/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/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/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) { From 7d7c0b59e54506f5998a34ea5cbbafc482416820 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:15:14 -0700 Subject: [PATCH 02/10] fix: remove stale useAutoUpdater duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both useAutoUpdater.ts and .tsx existed; Vite resolves .ts first, which accepts showToast but never implements it — so the update toast App.tsx asks for never rendered. The .tsx variant is the full implementation and has the same signature. --- app/src/hooks/useAutoUpdater.ts | 61 --------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 app/src/hooks/useAutoUpdater.ts 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, - }; -} From 91cdaa916201b4ce11d7e5352b2481ad6e308278 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:15:14 -0700 Subject: [PATCH 03/10] fix: use the shared queryClient in both entries, init i18n explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each entry built its own QueryClient while serverStore invalidates the singleton from lib/queryClient — so switching server URL never refreshed data. Both entries now render with the singleton. i18n init was only reachable transitively through lib/utils/format.ts; import it directly in both entries. --- tauri/src/main.tsx | 15 +++------------ web/src/main.tsx | 15 +++------------ 2 files changed, 6 insertions(+), 24 deletions(-) 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/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( From 696e927f6db71f0b543c162953b81e943f37d3fa Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:15:14 -0700 Subject: [PATCH 04/10] fix: gate DictateWindow tauri IPC behind platform.metadata.isTauri --- app/src/components/DictateWindow/DictateWindow.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 From 6174cd64d66c89e4fd92ba6ff51d0124e52ef447 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:15:14 -0700 Subject: [PATCH 05/10] chore: drop dead code, export routeTree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the orphaned generated OpenAPI client (superseded by the hand-written client.ts), the unrouted AudioTab, and app/'s unused standalone entry (main.tsx, index.html, vite.config) — app/ is a shared library consumed by web/ and tauri/. routeTree is exported so tests can build routers over memory history. --- app/index.html | 29 - app/package.json | 3 - app/src/components/AudioTab/AudioTab.tsx | 675 ------------------ app/src/lib/api/core/ApiError.ts | 25 - app/src/lib/api/core/ApiRequestOptions.ts | 17 - app/src/lib/api/core/ApiResult.ts | 11 - app/src/lib/api/core/CancelablePromise.ts | 130 ---- app/src/lib/api/core/OpenAPI.ts | 32 - app/src/lib/api/core/request.ts | 341 --------- app/src/lib/api/index.ts | 44 -- ...mple_profiles__profile_id__samples_post.ts | 8 - .../Body_transcribe_audio_transcribe_post.ts | 8 - app/src/lib/api/models/GenerationRequest.ts | 15 - app/src/lib/api/models/GenerationResponse.ts | 18 - app/src/lib/api/models/HTTPValidationError.ts | 8 - app/src/lib/api/models/HealthResponse.ts | 15 - app/src/lib/api/models/HistoryListResponse.ts | 12 - app/src/lib/api/models/HistoryResponse.ts | 19 - .../lib/api/models/ModelDownloadRequest.ts | 10 - app/src/lib/api/models/ModelStatus.ts | 15 - .../lib/api/models/ModelStatusListResponse.ts | 11 - .../lib/api/models/ProfileSampleResponse.ts | 13 - .../lib/api/models/TranscriptionResponse.ts | 11 - app/src/lib/api/models/ValidationError.ts | 9 - app/src/lib/api/models/VoiceProfileCreate.ts | 12 - .../lib/api/models/VoiceProfileResponse.ts | 15 - ...mple_profiles__profile_id__samples_post.ts | 17 - .../$Body_transcribe_audio_transcribe_post.ts | 24 - app/src/lib/api/schemas/$GenerationRequest.ts | 46 -- .../lib/api/schemas/$GenerationResponse.ts | 50 -- .../lib/api/schemas/$HTTPValidationError.ts | 14 - app/src/lib/api/schemas/$HealthResponse.ts | 54 -- .../lib/api/schemas/$HistoryListResponse.ts | 20 - app/src/lib/api/schemas/$HistoryResponse.ts | 54 -- .../lib/api/schemas/$ModelDownloadRequest.ts | 13 - app/src/lib/api/schemas/$ModelStatus.ts | 35 - .../api/schemas/$ModelStatusListResponse.ts | 16 - .../lib/api/schemas/$ProfileSampleResponse.ts | 25 - .../lib/api/schemas/$TranscriptionResponse.ts | 17 - app/src/lib/api/schemas/$ValidationError.ts | 31 - .../lib/api/schemas/$VoiceProfileCreate.ts | 31 - .../lib/api/schemas/$VoiceProfileResponse.ts | 43 -- app/src/lib/api/services/DefaultService.ts | 459 ------------ app/src/main.tsx | 17 - app/src/router.tsx | 6 +- app/vite.config.ts | 14 - 46 files changed, 3 insertions(+), 2489 deletions(-) delete mode 100644 app/index.html delete mode 100644 app/src/components/AudioTab/AudioTab.tsx delete mode 100644 app/src/lib/api/core/ApiError.ts delete mode 100644 app/src/lib/api/core/ApiRequestOptions.ts delete mode 100644 app/src/lib/api/core/ApiResult.ts delete mode 100644 app/src/lib/api/core/CancelablePromise.ts delete mode 100644 app/src/lib/api/core/OpenAPI.ts delete mode 100644 app/src/lib/api/core/request.ts delete mode 100644 app/src/lib/api/index.ts delete mode 100644 app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts delete mode 100644 app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts delete mode 100644 app/src/lib/api/models/GenerationRequest.ts delete mode 100644 app/src/lib/api/models/GenerationResponse.ts delete mode 100644 app/src/lib/api/models/HTTPValidationError.ts delete mode 100644 app/src/lib/api/models/HealthResponse.ts delete mode 100644 app/src/lib/api/models/HistoryListResponse.ts delete mode 100644 app/src/lib/api/models/HistoryResponse.ts delete mode 100644 app/src/lib/api/models/ModelDownloadRequest.ts delete mode 100644 app/src/lib/api/models/ModelStatus.ts delete mode 100644 app/src/lib/api/models/ModelStatusListResponse.ts delete mode 100644 app/src/lib/api/models/ProfileSampleResponse.ts delete mode 100644 app/src/lib/api/models/TranscriptionResponse.ts delete mode 100644 app/src/lib/api/models/ValidationError.ts delete mode 100644 app/src/lib/api/models/VoiceProfileCreate.ts delete mode 100644 app/src/lib/api/models/VoiceProfileResponse.ts delete mode 100644 app/src/lib/api/schemas/$Body_add_profile_sample_profiles__profile_id__samples_post.ts delete mode 100644 app/src/lib/api/schemas/$Body_transcribe_audio_transcribe_post.ts delete mode 100644 app/src/lib/api/schemas/$GenerationRequest.ts delete mode 100644 app/src/lib/api/schemas/$GenerationResponse.ts delete mode 100644 app/src/lib/api/schemas/$HTTPValidationError.ts delete mode 100644 app/src/lib/api/schemas/$HealthResponse.ts delete mode 100644 app/src/lib/api/schemas/$HistoryListResponse.ts delete mode 100644 app/src/lib/api/schemas/$HistoryResponse.ts delete mode 100644 app/src/lib/api/schemas/$ModelDownloadRequest.ts delete mode 100644 app/src/lib/api/schemas/$ModelStatus.ts delete mode 100644 app/src/lib/api/schemas/$ModelStatusListResponse.ts delete mode 100644 app/src/lib/api/schemas/$ProfileSampleResponse.ts delete mode 100644 app/src/lib/api/schemas/$TranscriptionResponse.ts delete mode 100644 app/src/lib/api/schemas/$ValidationError.ts delete mode 100644 app/src/lib/api/schemas/$VoiceProfileCreate.ts delete mode 100644 app/src/lib/api/schemas/$VoiceProfileResponse.ts delete mode 100644 app/src/lib/api/services/DefaultService.ts delete mode 100644 app/src/main.tsx delete mode 100644 app/vite.config.ts 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/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/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/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/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'), - }, - }, -}); From fddcd61f4df169da44d43706be17ad27a4873993 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:22:28 -0700 Subject: [PATCH 06/10] test: vitest foundation with browser mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two projects in one root config: 'unit' (happy-dom) for stores, hooks, and utils, and 'browser' (real Chromium via the playwright provider) for component tests — no jsdom polyfills for EventSource, AudioContext, or canvas. Harness pieces: createMockPlatform (spy-able Platform with an updater emit handle), renderWithProviders (fresh QueryClient with polling disabled + PlatformProvider), MSW worker with baseline health handler, and a store-reset teardown covering all eight zustand stores. Seed tests cover uiStore theme, serverStore invalidation, and an AboutPage render in Chromium. Requires node >= 20 (.node-version added); run with bun run test. --- .node-version | 1 + ...b88173a3c966ff32fd6131b2a7f621a51b4b8d.png | Bin 0 -> 11678 bytes .../ServerTab/AboutPage.browser.test.tsx | 15 + ...nders-and-shows-the-platform-version-1.png | Bin 0 -> 11678 bytes app/src/stores/serverStore.test.ts | 37 ++ app/src/stores/uiStore.test.ts | 27 ++ app/src/test/mockPlatform.ts | 86 +++++ app/src/test/msw/handlers/server.ts | 17 + app/src/test/msw/worker.ts | 9 + app/src/test/public/mockServiceWorker.js | 346 ++++++++++++++++++ app/src/test/render.tsx | 41 +++ app/src/test/resetStores.ts | 36 ++ app/src/test/setup.browser.ts | 11 + app/src/test/setup.ts | 8 + bun.lock | 312 +++++++++++++++- package.json | 12 +- vitest.config.ts | 47 +++ 17 files changed, 999 insertions(+), 6 deletions(-) create mode 100644 .node-version create mode 100644 .vitest-attachments/3cb88173a3c966ff32fd6131b2a7f621a51b4b8d.png create mode 100644 app/src/components/ServerTab/AboutPage.browser.test.tsx create mode 100644 app/src/components/ServerTab/__screenshots__/AboutPage.browser.test.tsx/renders-and-shows-the-platform-version-1.png create mode 100644 app/src/stores/serverStore.test.ts create mode 100644 app/src/stores/uiStore.test.ts create mode 100644 app/src/test/mockPlatform.ts create mode 100644 app/src/test/msw/handlers/server.ts create mode 100644 app/src/test/msw/worker.ts create mode 100644 app/src/test/public/mockServiceWorker.js create mode 100644 app/src/test/render.tsx create mode 100644 app/src/test/resetStores.ts create mode 100644 app/src/test/setup.browser.ts create mode 100644 app/src/test/setup.ts create mode 100644 vitest.config.ts 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 0000000000000000000000000000000000000000..4087dd2e49e5b36400dbbd921360752f3a84f7fd GIT binary patch literal 11678 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2R5Xj_U0*ZvORhQ8V;k0U^Fv~mIR|E;%Id+S}l&Y z2}avSqYcB+R`F=7c(heK+A1E(t>T?`77M_}0!GvFXj&f1X?ZmLj;7zy^gB92F*-sq zIzllzLNPi*F*-sqIzllzLNVGZ9&Hs9*DC((BF+I^FJKK?7ckn29BoC8wjxJck)y51 z(N^SWD{{0IIogUGZAB8-iX3e@4&#>NzR`8Tcv{8#_!$`f|9>z2Ef2WLnhn&AVrJMT WqU1OdA literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4087dd2e49e5b36400dbbd921360752f3a84f7fd GIT binary patch literal 11678 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2R5Xj_U0*ZvORhQ8V;k0U^Fv~mIR|E;%Id+S}l&Y z2}avSqYcB+R`F=7c(heK+A1E(t>T?`77M_}0!GvFXj&f1X?ZmLj;7zy^gB92F*-sq zIzllzLNPi*F*-sqIzllzLNVGZ9&Hs9*DC((BF+I^FJKK?7ckn29BoC8wjxJck)y51 z(N^SWD{{0IIogUGZAB8-iX3e@4&#>NzR`8Tcv{8#_!$`f|9>z2Ef2WLnhn&AVrJMT WqU1OdA literal 0 HcmV?d00001 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/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/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..f54c5b0d1 --- /dev/null +++ b/app/src/test/render.tsx @@ -0,0 +1,41 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { render } from 'vitest-browser-react'; +import { PlatformProvider } from '@/platform/PlatformContext'; +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; +} + +export async function renderWithProviders(ui: ReactNode, options: RenderWithProvidersOptions = {}) { + const platform = options.platform ?? createMockPlatform(); + const queryClient = options.queryClient ?? createTestQueryClient(); + + 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 }); +} 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..cd83ad5fb --- /dev/null +++ b/app/src/test/setup.browser.ts @@ -0,0 +1,11 @@ +import { afterEach, beforeAll } from 'vitest'; +import { worker } from './msw/worker'; + +beforeAll(async () => { + await worker.start({ onUnhandledRequest: 'error', quiet: true }); + return () => worker.stop(); +}); + +afterEach(() => { + 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/bun.lock b/bun.lock index e9491131c..76e4b201b 100644 --- a/bun.lock +++ b/bun.lock @@ -11,8 +11,16 @@ "devDependencies": { "@biomejs/biome": "2.3.12", "@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 +188,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 +268,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 +288,24 @@ "@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=="], + + "@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 +390,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 +470,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 +564,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 +578,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 +604,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 +646,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 +658,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 +686,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 +710,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 +742,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 +756,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 +780,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 +800,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 +826,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 +910,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 +926,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 +946,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 +994,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 +1018,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 +1068,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 +1112,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 +1192,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/package.json b/package.json index b1e5682dd..6ec9e0527 100644 --- a/package.json +++ b/package.json @@ -27,13 +27,23 @@ "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", "@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/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..c51f45463 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,47 @@ +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'), + 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' }], + }, + }, + }, + ], + }, +}); From c7a3ff5d65e75ca447e410f6bf9b01eb125886bc Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:26:53 -0700 Subject: [PATCH 07/10] =?UTF-8?q?test:=20harness=20=E2=80=94=20typed=20fix?= =?UTF-8?q?tures,=20domain=20MSW=20handlers,=20SSE=20helpers,=20route=20re?= =?UTF-8?q?nder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixture builders match the hand-written API types with deterministic ids. Handlers are per-domain factories tests compose via worker.use; unstubbed requests fail loudly. sseController streams real text/event-stream bodies through MSW into EventSource, so generation and download progress are testable without touching the transport. renderRoute mounts the real route tree over memory history. Query clients drain on teardown so in-flight fetches can't leak past handler reset, and the browser project pre-bundles app deps so a cold dep- optimizer cache can't reload mid-run. --- app/src/test/harness.browser.test.tsx | 58 +++++++ app/src/test/msw/fixtures.ts | 216 ++++++++++++++++++++++++++ app/src/test/msw/handlers/index.ts | 102 ++++++++++++ app/src/test/render.tsx | 30 ++++ app/src/test/setup.browser.ts | 9 +- app/src/test/sse.ts | 81 ++++++++++ vitest.config.ts | 33 ++++ 7 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 app/src/test/harness.browser.test.tsx create mode 100644 app/src/test/msw/fixtures.ts create mode 100644 app/src/test/msw/handlers/index.ts create mode 100644 app/src/test/sse.ts diff --git a/app/src/test/harness.browser.test.tsx b/app/src/test/harness.browser.test.tsx new file mode 100644 index 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/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/render.tsx b/app/src/test/render.tsx index f54c5b0d1..b7b41ad45 100644 --- a/app/src/test/render.tsx +++ b/app/src/test/render.tsx @@ -1,7 +1,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router'; import type { ReactNode } from 'react'; import { render } from 'vitest-browser-react'; import { PlatformProvider } from '@/platform/PlatformContext'; +import { routeTree } from '@/router'; import { createMockPlatform, type MockPlatform } from './mockPlatform'; export function createTestQueryClient(): QueryClient { @@ -25,9 +27,23 @@ export interface RenderWithProvidersOptions { queryClient?: QueryClient; } +// Every client handed to a render is drained on teardown so in-flight +// queries can't fire after MSW handlers reset (noisy unhandled-request +// errors between tests). +const activeQueryClients: QueryClient[] = []; + +export async function drainQueryClients(): Promise { + for (const client of activeQueryClients) { + await client.cancelQueries(); + client.clear(); + } + activeQueryClients.length = 0; +} + export async function renderWithProviders(ui: ReactNode, options: RenderWithProvidersOptions = {}) { const platform = options.platform ?? createMockPlatform(); const queryClient = options.queryClient ?? createTestQueryClient(); + activeQueryClients.push(queryClient); const result = await render( @@ -39,3 +55,17 @@ export async function renderWithProviders(ui: ReactNode, options: RenderWithProv // intact — spreading would drop them. return Object.assign(result, { platform, queryClient }); } + +/** + * Mount the real route tree at `route` over memory history — full app chrome + * (sidebar, frame, toasts) included. A throwaway router per call keeps route + * state from leaking between tests. + */ +export async function renderRoute(route: string, options: RenderWithProvidersOptions = {}) { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [route] }), + }); + const result = await renderWithProviders(, options); + return Object.assign(result, { router }); +} diff --git a/app/src/test/setup.browser.ts b/app/src/test/setup.browser.ts index cd83ad5fb..e0b6d1d13 100644 --- a/app/src/test/setup.browser.ts +++ b/app/src/test/setup.browser.ts @@ -1,11 +1,18 @@ import { afterEach, beforeAll } from 'vitest'; +import { cleanup } from 'vitest-browser-react'; import { worker } from './msw/worker'; +import { drainQueryClients } from './render'; beforeAll(async () => { await worker.start({ onUnhandledRequest: 'error', quiet: true }); return () => worker.stop(); }); -afterEach(() => { +// Registered after setup.ts, so this runs first (afterEach is LIFO): +// unmount → cancel in-flight queries → reset handlers, then setup.ts +// restores stores and mocks. +afterEach(async () => { + await cleanup(); + await drainQueryClients(); worker.resetHandlers(); }); diff --git a/app/src/test/sse.ts b/app/src/test/sse.ts new file mode 100644 index 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/vitest.config.ts b/vitest.config.ts index c51f45463..bee089548 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,6 +30,39 @@ export default defineConfig({ { ...shared, publicDir: path.resolve(__dirname, 'app/src/test/public'), + // Pre-bundle everything the app pulls in so the dep optimizer never + // reloads mid-run (a cold cache otherwise fails the first CI run). + optimizeDeps: { + include: [ + 'react', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@tanstack/react-query', + '@tanstack/react-router', + 'zustand', + 'zustand/middleware', + 'i18next', + 'react-i18next', + 'i18next-browser-languagedetector', + 'framer-motion', + 'motion/react', + 'lucide-react', + 'wavesurfer.js', + '@dnd-kit/core', + '@dnd-kit/sortable', + '@dnd-kit/utilities', + 'react-hook-form', + '@hookform/resolvers/zod', + 'zod', + 'clsx', + 'tailwind-merge', + 'class-variance-authority', + 'date-fns', + 'msw', + 'vitest-browser-react', + ], + }, test: { name: 'browser', include: ['app/src/**/*.browser.test.{ts,tsx}'], From 37b7110e526aa3663959c18ad58a618b789389c4 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:40:36 -0700 Subject: [PATCH 08/10] test: Playwright E2E with real backend + fake TTS engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e/ drives the web build (vite preview) against a per-worker uvicorn: each Playwright worker boots its own backend on its own port with a temp cwd, so SQLite and audio files are fully isolated and parallel- safe. The page fixture seeds the persisted voicebox-server store with the worker's URL before any script runs. VOICEBOX_FAKE_TTS=1 short-circuits get_tts_backend_for_engine to a backend that synthesizes a sine tone sized to the text — the real task queue, SSE progress, history rows, and audio serving all run, only inference is fake. backend/requirements-ci.txt is the slim dependency set validated to boot the app on a CPU-only runner. Specs: startup, settings layout, seeded profile in /voices, and generate-to-completed-audio through the full pipeline. --- .gitignore | Bin 948 -> 1056 bytes backend/backends/__init__.py | 8 +++ backend/backends/fake_backend.py | 92 ++++++++++++++++++++++++++++ backend/requirements-ci.txt | 25 ++++++++ bun.lock | 3 + e2e/fixtures.ts | 99 +++++++++++++++++++++++++++++++ e2e/helpers/api.ts | 58 ++++++++++++++++++ e2e/playwright.config.ts | 36 +++++++++++ e2e/specs/generate.spec.ts | 31 ++++++++++ e2e/specs/startup.spec.ts | 16 +++++ e2e/specs/voices.spec.ts | 10 ++++ package.json | 1 + 12 files changed, 379 insertions(+) create mode 100644 backend/backends/fake_backend.py create mode 100644 backend/requirements-ci.txt create mode 100644 e2e/fixtures.ts create mode 100644 e2e/helpers/api.ts create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/specs/generate.spec.ts create mode 100644 e2e/specs/startup.spec.ts create mode 100644 e2e/specs/voices.spec.ts diff --git a/.gitignore b/.gitignore index 853c5060975fbeadd1fbd442d6dfb9c9a648813d..3b6c46e14469f4ba06d37ef0b75f28f869c84ab4 100644 GIT binary patch delta 116 zcmW;C!41MN3`XGt2au}@=r-jV F=?BcEDrf)z delta 7 OcmZ3$v4wrZ7G?kovI5Hh 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 76e4b201b..c72f6b8ef 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ }, "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", @@ -304,6 +305,8 @@ "@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=="], 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 6ec9e0527..5e1120b14 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "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", From bfd6ec2f02d7c7a89e119791b3f1aec2e407afe3 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:41:11 -0700 Subject: [PATCH 09/10] ci: unit, e2e, and backend-test jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quality keeps typecheck + web build and uploads the bundle. unit runs vitest (node + Chromium browser projects) and blocks PRs. e2e boots the CPU backend with fake TTS and runs Playwright against the preview build — informational until it has a sustained green run, then flip continue-on-error off. backend-tests runs the existing pytest suite for the first time in CI, also informational. --- .github/workflows/ci.yml | 115 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) 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 From cd116bcfe7723e04535f1d5f4207ccd8d405a2d2 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sat, 8 Aug 2026 15:42:01 -0700 Subject: [PATCH 10/10] test: App startup, ChordPicker, HistoryTable, and generate-flow suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App: non-Tauri skips the startup gate, Tauri dev reaches the router with close-handler/updater wiring, ?view=dictate renders the pill without booting the main app (branches behind import.meta.env.PROD are unreachable under vitest and stay uncovered). ChordPicker: chord capture via raw KeyboardEvents with explicit codes, peak-set semantics, save/cancel. HistoryTable: rows, empty state, player-store autoplay intent, favorite/delete round trips, export through platform.filesystem.saveFile. FloatingGenerateBox: mounted via the real index route so submit → pending → SSE completion → history refetch runs the actual RootLayout wiring. --- app/src/App.browser.test.tsx | 134 +++++++++++++ .../ChordPicker/ChordPicker.browser.test.tsx | 113 +++++++++++ .../FloatingGenerateBox.browser.test.tsx | 185 ++++++++++++++++++ .../History/HistoryTable.browser.test.tsx | 133 +++++++++++++ 4 files changed, 565 insertions(+) create mode 100644 app/src/App.browser.test.tsx create mode 100644 app/src/components/ChordPicker/ChordPicker.browser.test.tsx create mode 100644 app/src/components/Generation/FloatingGenerateBox.browser.test.tsx create mode 100644 app/src/components/History/HistoryTable.browser.test.tsx 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/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/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'] }]); +});