From 49c03afd7f7de22e97b51a8c679338e1b3732855 Mon Sep 17 00:00:00 2001
From: John Tennant
Date: Mon, 27 Jul 2026 22:06:31 -0400
Subject: [PATCH] feat(desktop): import local Pocket voices
Signed-off-by: John Tennant
---
desktop/src-tauri/src/huddle/mod.rs | 1 +
desktop/src-tauri/src/huddle/pipeline.rs | 63 ++-
desktop/src-tauri/src/huddle/tts_settings.rs | 188 +++++++-
.../src-tauri/src/huddle/tts_voice_import.rs | 451 ++++++++++++++++++
.../src/huddle/tts_voice_transition.rs | 11 +-
desktop/src-tauri/src/lib.rs | 2 +
.../settings/ui/VoiceSettingsCard.tsx | 120 ++++-
desktop/src/testing/e2eBridge.ts | 288 +++++++----
desktop/tests/e2e/voice-settings.spec.ts | 39 ++
9 files changed, 1018 insertions(+), 145 deletions(-)
create mode 100644 desktop/src-tauri/src/huddle/tts_voice_import.rs
diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs
index 04c675c99..f33cbf293 100644
--- a/desktop/src-tauri/src/huddle/mod.rs
+++ b/desktop/src-tauri/src/huddle/mod.rs
@@ -39,6 +39,7 @@ pub mod stt;
pub mod transcription;
pub mod tts;
pub mod tts_settings;
+mod tts_voice_import;
mod tts_voice_registry;
pub mod wire;
diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs
index 852e84287..e47b0d8e3 100644
--- a/desktop/src-tauri/src/huddle/pipeline.rs
+++ b/desktop/src-tauri/src/huddle/pipeline.rs
@@ -195,6 +195,40 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result return Ok(false),
};
+ // Avoid resolving and hashing imported voice files on every hot-start poll
+ // when TTS is already disabled or running. The guarded claim below repeats
+ // these checks after the fallible work to close the race.
+ {
+ let huddle = state.huddle()?;
+ if huddle.tts_pipeline.is_some() || !huddle.tts_enabled {
+ return Ok(false);
+ }
+ }
+
+ // Resolve all fallible construction inputs before claiming the sentinel so
+ // an unreadable optional voice registry cannot wedge future start attempts.
+ let output_device = state
+ .huddle_audio
+ .output_device
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .clone();
+ let app = state
+ .app_handle
+ .lock()
+ .map_err(|error| format!("app handle lock poisoned: {error}"))?
+ .clone();
+ let voice_preferences = state
+ .huddle_audio
+ .tts
+ .lock()
+ .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))
+ .map(|settings| settings.voice_preferences.clone())?;
+ let initial_voice = match app {
+ Some(app) => super::tts_settings::pocket_voice_reference(&app, &voice_preferences)?,
+ None => super::tts_settings::bundled_pocket_voice_reference(&voice_preferences),
+ };
+
// Atomically check preconditions and claim the construction slot.
// The sentinel prevents a second caller from starting construction
// while we're building outside the lock.
@@ -219,20 +253,6 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result super::tts_settings::pocket_voice_reference(&app, &preferences)?,
+ None => super::tts_settings::bundled_pocket_voice_reference(&preferences),
+ };
publish(&voice, &mut huddle);
Ok(true)
}
diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs
index a3e931dff..14e9c7d7e 100644
--- a/desktop/src-tauri/src/huddle/tts_settings.rs
+++ b/desktop/src-tauri/src/huddle/tts_settings.rs
@@ -86,7 +86,7 @@ pub type VoicePreferences = Vec;
/// V1 contains Pocket entries only. Siri, Kokoro, imported voices, and
/// per-agent assignment can add entries or reuse the preference type without
/// changing the registry/settings boundary.
-pub fn voice_registry() -> Vec {
+pub fn bundled_voice_registry() -> Vec {
POCKET_VOICES
.iter()
.map(|voice| VoiceRegistryEntry {
@@ -107,6 +107,33 @@ pub fn voice_registry() -> Vec {
.collect()
}
+pub fn voice_registry(app: &AppHandle) -> Vec {
+ let mut registry = bundled_voice_registry();
+ match super::tts_voice_import::load_registry(app) {
+ Ok(imported) => registry.extend(imported.into_iter().map(|voice| VoiceRegistryEntry {
+ key: voice.key,
+ display_name: voice.display_name,
+ backend: POCKET_BACKEND_ID.to_string(),
+ backend_name: "Pocket TTS".to_string(),
+ availability: VOICE_AVAILABILITY_INSTALLED.to_string(),
+ fallback_key: Some(MARY_VOICE_KEY.to_string()),
+ reference_file: Some(voice.file_name),
+ provenance: VoiceProvenance {
+ source: "local import".to_string(),
+ content_hash: Some(voice.content_hash),
+ license: None,
+ source_url: None,
+ },
+ })),
+ Err(error) => {
+ eprintln!(
+ "buzz-desktop: {error}; imported Pocket voices are unavailable for this session"
+ );
+ }
+ }
+ registry
+}
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TtsSettings {
@@ -125,8 +152,10 @@ impl Default for TtsSettings {
}
}
-pub fn voice_by_key(key: &str) -> Option {
- voice_registry().into_iter().find(|voice| voice.key == key)
+pub fn voice_by_key(app: &AppHandle, key: &str) -> Option {
+ voice_registry(app)
+ .into_iter()
+ .find(|voice| voice.key == key)
}
fn is_qualified_voice_key(key: &str) -> bool {
@@ -141,11 +170,19 @@ fn is_locally_available(availability: &str) -> bool {
)
}
+#[cfg(test)]
pub fn resolve_voice_for_backend(
preferences: &[String],
backend: &str,
) -> Result {
- let registry = voice_registry();
+ resolve_voice_for_backend_in_registry(preferences, backend, &bundled_voice_registry())
+}
+
+fn resolve_voice_for_backend_in_registry(
+ preferences: &[String],
+ backend: &str,
+ registry: &[VoiceRegistryEntry],
+) -> Result {
preferences
.iter()
.filter_map(|key| registry.iter().find(|voice| voice.key == *key))
@@ -161,14 +198,31 @@ pub fn resolve_voice_for_backend(
.ok_or_else(|| format!("No locally available fallback voice for backend {backend}"))
}
-pub fn pocket_voice_name(preferences: &[String]) -> String {
- resolve_voice_for_backend(preferences, POCKET_BACKEND_ID)
+pub fn bundled_pocket_voice_reference(preferences: &[String]) -> String {
+ resolve_voice_for_backend_in_registry(preferences, POCKET_BACKEND_ID, &bundled_voice_registry())
.ok()
.and_then(|voice| voice.reference_file)
.and_then(|file| file.strip_suffix(".wav").map(str::to_string))
.unwrap_or_else(|| DEFAULT_VOICE.to_string())
}
+pub fn pocket_voice_reference(app: &AppHandle, preferences: &[String]) -> Result {
+ let registry = voice_registry(app);
+ let voice = resolve_voice_for_backend_in_registry(preferences, POCKET_BACKEND_ID, ®istry)?;
+ if voice.key.starts_with("pocket:imported:") {
+ let imported = super::tts_voice_import::load_registry(app)?
+ .into_iter()
+ .find(|candidate| candidate.key == voice.key)
+ .ok_or_else(|| format!("Imported voice {} is unavailable", voice.display_name))?;
+ return super::tts_voice_import::resolve_file(app, &imported)
+ .map(|path| path.to_string_lossy().into_owned());
+ }
+ Ok(voice
+ .reference_file
+ .and_then(|file| file.strip_suffix(".wav").map(str::to_string))
+ .unwrap_or_else(|| DEFAULT_VOICE.to_string()))
+}
+
pub(crate) fn settings_path(app: &AppHandle) -> Result {
app.path()
.app_data_dir()
@@ -290,8 +344,8 @@ pub fn get_tts_settings(state: State<'_, AppState>) -> Result Vec {
- voice_registry()
+pub fn list_voice_registry(app: AppHandle) -> Vec {
+ voice_registry(&app)
}
fn ensure_settings_writable(state: &AppState) -> Result<(), String> {
@@ -394,8 +448,8 @@ async fn apply_tts_settings(
if settings.agent_text_to_speech {
let (active, voice_change_ack) = {
let mut huddle = state.huddle()?;
- let voice_change_ack =
- enable_tts_runtime(&mut huddle, &pocket_voice_name(&settings.voice_preferences));
+ let voice_reference = pocket_voice_reference(app, &settings.voice_preferences)?;
+ let voice_change_ack = enable_tts_runtime(&mut huddle, &voice_reference);
(
matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active),
voice_change_ack,
@@ -479,10 +533,22 @@ pub async fn set_tts_enabled(
}
fn settings_with_pocket_voice(
+ settings: TtsSettings,
+ voice_key: &str,
+ app: &AppHandle,
+) -> Result {
+ settings_with_pocket_voice_from_registry(settings, voice_key, &voice_registry(app))
+}
+
+fn settings_with_pocket_voice_from_registry(
mut settings: TtsSettings,
voice_key: &str,
+ registry: &[VoiceRegistryEntry],
) -> Result {
- let voice = voice_by_key(voice_key).ok_or_else(|| format!("Unknown voice: {voice_key}"))?;
+ let voice = registry
+ .iter()
+ .find(|voice| voice.key == voice_key)
+ .ok_or_else(|| format!("Unknown voice: {voice_key}"))?;
if voice.backend != POCKET_BACKEND_ID || !is_locally_available(&voice.availability) {
return Err("The selected Pocket voice is not available on this device".to_string());
}
@@ -515,7 +581,7 @@ pub async fn set_pocket_voice(
.lock()
.map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))?
.clone();
- let settings = settings_with_pocket_voice(settings, &voice_key)?;
+ let settings = settings_with_pocket_voice(settings, &voice_key, &app)?;
let voice_change = apply_tts_settings(settings, &app, &state).await?;
drop(transition);
if let Err(error) = finish_voice_change(voice_change).await {
@@ -532,9 +598,11 @@ pub async fn set_pocket_voice(
#[tauri::command]
pub async fn preview_pocket_voice(
voice_key: String,
+ app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
- let voice = voice_by_key(&voice_key).ok_or_else(|| format!("Unknown voice: {voice_key}"))?;
+ let voice =
+ voice_by_key(&app, &voice_key).ok_or_else(|| format!("Unknown voice: {voice_key}"))?;
if voice.backend != POCKET_BACKEND_ID {
return Err("Only Pocket voices can be previewed in this build".to_string());
}
@@ -548,10 +616,7 @@ pub async fn preview_pocket_voice(
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone();
- let voice_name = voice
- .reference_file
- .and_then(|file| file.strip_suffix(".wav").map(str::to_string))
- .ok_or_else(|| format!("Voice {voice_key} has no local Pocket reference file"))?;
+ let voice_name = pocket_voice_reference(&app, std::slice::from_ref(&voice_key))?;
tokio::task::spawn_blocking(move || {
let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
@@ -579,6 +644,69 @@ pub async fn preview_pocket_voice(
.map_err(|error| format!("Voice preview task failed: {error}"))?
}
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TtsVoiceMutation {
+ pub settings: TtsSettings,
+ pub registry: Vec,
+}
+
+#[tauri::command]
+pub async fn import_pocket_voice(
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result
)}
+ {
+ if (!open) setDeleteCandidate(null);
+ }}
+ open={deleteCandidate !== null}
+ >
+
+
+ Delete imported voice?
+
+ {deleteCandidate
+ ? `${deleteCandidate.displayName} and its local audio file will be removed.`
+ : "This imported voice and its local audio file will be removed."}
+ {selectedVoice?.key === deleteCandidate?.key &&
+ " Mary will be selected instead."}
+
+
+
+ Cancel
+ {
+ event.preventDefault();
+ if (deleteCandidate) {
+ void deletePocketVoice(deleteCandidate.key);
+ }
+ }}
+ >
+ Delete voice
+
+
+
+
);
}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 73195ae0c..13334827a 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -9665,7 +9665,25 @@ export function maybeInstallE2eTauriMocks() {
deviceId: state === "running" ? "mock-endpoint-id" : null,
deviceName: state === "running" ? "Mock desktop" : null,
});
- const handleMockCommand = async (command: string, payload: unknown) => {
+ let mockImportedVoices: Array<{
+ key: string;
+ displayName: string;
+ backend: string;
+ backendName: string;
+ availability: "installed";
+ fallbackKey: string;
+ referenceFile: string;
+ provenance: {
+ source: string;
+ contentHash: string;
+ license: null;
+ sourceUrl: null;
+ };
+ }> = [];
+ const handleMockCommand = async (
+ command: string,
+ payload: unknown,
+ ): Promise => {
const activeConfig = getConfig();
const identity = getActiveIdentity(activeConfig);
window.__BUZZ_E2E_COMMANDS__?.push(command);
@@ -9693,107 +9711,110 @@ export function maybeInstallE2eTauriMocks() {
);
case "list_voice_registry":
return [
- [
- "anna",
- "Anna",
- "anna.wav",
- "p228_023_enhanced.wav",
- "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856",
- ],
- [
- "vera",
- "Vera",
- "vera.wav",
- "p229_023_enhanced.wav",
- "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b",
- ],
- [
- "fantine",
- "Fantine",
- "fantine.wav",
- "p244_023_enhanced.wav",
- "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b",
- ],
- [
- "charles",
- "Charles",
- "charles.wav",
- "p254_023_enhanced.wav",
- "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756",
- ],
- [
- "paul",
- "Paul",
- "paul.wav",
- "p259_023_enhanced.wav",
- "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b",
- ],
- [
- "eponine",
- "Eponine",
- "eponine.wav",
- "p262_023_enhanced.wav",
- "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b",
- ],
- [
- "azelma",
- "Azelma",
- "azelma.wav",
- "p303_023_enhanced.wav",
- "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026",
- ],
- [
- "george",
- "George",
- "george.wav",
- "p315_023_enhanced.wav",
- "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae",
- ],
- [
- "mary",
- "Mary",
- "reference_sample.wav",
- "p333_023_enhanced.wav",
- "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f",
- ],
- [
- "jane",
- "Jane",
- "jane.wav",
- "p339_023_enhanced.wav",
- "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a",
- ],
- [
- "michael",
- "Michael",
- "michael.wav",
- "p360_023_enhanced.wav",
- "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad",
- ],
- [
- "eve",
- "Eve",
- "eve.wav",
- "p361_023_enhanced.wav",
- "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd",
- ],
- ].map(
- ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({
- key: `pocket:${id}`,
- displayName,
- backend: "pocket",
- backendName: "Pocket TTS",
- availability: "bundled",
- fallbackKey: id === "mary" ? null : "pocket:mary",
- referenceFile,
- provenance: {
- source: "bundled",
- contentHash,
- license: "CC-BY-4.0",
- sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`,
- },
- }),
- );
+ ...[
+ [
+ "anna",
+ "Anna",
+ "anna.wav",
+ "p228_023_enhanced.wav",
+ "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856",
+ ],
+ [
+ "vera",
+ "Vera",
+ "vera.wav",
+ "p229_023_enhanced.wav",
+ "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b",
+ ],
+ [
+ "fantine",
+ "Fantine",
+ "fantine.wav",
+ "p244_023_enhanced.wav",
+ "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b",
+ ],
+ [
+ "charles",
+ "Charles",
+ "charles.wav",
+ "p254_023_enhanced.wav",
+ "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756",
+ ],
+ [
+ "paul",
+ "Paul",
+ "paul.wav",
+ "p259_023_enhanced.wav",
+ "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b",
+ ],
+ [
+ "eponine",
+ "Eponine",
+ "eponine.wav",
+ "p262_023_enhanced.wav",
+ "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b",
+ ],
+ [
+ "azelma",
+ "Azelma",
+ "azelma.wav",
+ "p303_023_enhanced.wav",
+ "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026",
+ ],
+ [
+ "george",
+ "George",
+ "george.wav",
+ "p315_023_enhanced.wav",
+ "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae",
+ ],
+ [
+ "mary",
+ "Mary",
+ "reference_sample.wav",
+ "p333_023_enhanced.wav",
+ "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f",
+ ],
+ [
+ "jane",
+ "Jane",
+ "jane.wav",
+ "p339_023_enhanced.wav",
+ "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a",
+ ],
+ [
+ "michael",
+ "Michael",
+ "michael.wav",
+ "p360_023_enhanced.wav",
+ "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad",
+ ],
+ [
+ "eve",
+ "Eve",
+ "eve.wav",
+ "p361_023_enhanced.wav",
+ "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd",
+ ],
+ ].map(
+ ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({
+ key: `pocket:${id}`,
+ displayName,
+ backend: "pocket",
+ backendName: "Pocket TTS",
+ availability: "bundled",
+ fallbackKey: id === "mary" ? null : "pocket:mary",
+ referenceFile,
+ provenance: {
+ source: "bundled",
+ contentHash,
+ license: "CC-BY-4.0",
+ sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`,
+ },
+ }),
+ ),
+ ...mockImportedVoices,
+ ];
case "set_tts_enabled": {
const enabled = (payload as { enabled?: boolean })?.enabled;
if (typeof enabled !== "boolean")
@@ -9838,6 +9859,69 @@ export function maybeInstallE2eTauriMocks() {
}
case "preview_pocket_voice":
return null;
+ case "import_pocket_voice": {
+ const contentHash = "1".repeat(64);
+ const imported = {
+ key: `pocket:imported:${contentHash}`,
+ displayName: "My voice",
+ backend: "pocket",
+ backendName: "Pocket TTS",
+ availability: "installed" as const,
+ fallbackKey: "pocket:mary",
+ referenceFile: `${contentHash}.wav`,
+ provenance: {
+ source: "local import",
+ contentHash,
+ license: null,
+ sourceUrl: null,
+ },
+ };
+ mockImportedVoices = [imported];
+ const current = activeConfig?.mock?.ttsSettings ?? {
+ version: 1,
+ agentTextToSpeech: true,
+ voicePreferences: ["pocket:mary"],
+ };
+ const settings = {
+ ...current,
+ voicePreferences: [imported.key],
+ };
+ if (activeConfig) {
+ activeConfig.mock ??= {};
+ activeConfig.mock.ttsSettings = settings;
+ }
+ return {
+ settings,
+ registry: await handleMockCommand("list_voice_registry", null),
+ };
+ }
+ case "delete_pocket_voice": {
+ const voiceKey = (payload as { voiceKey?: string })?.voiceKey;
+ if (!voiceKey?.startsWith("pocket:imported:"))
+ throw new Error("Missing imported Pocket voice key");
+ mockImportedVoices = mockImportedVoices.filter(
+ (voice) => voice.key !== voiceKey,
+ );
+ const current = activeConfig?.mock?.ttsSettings ?? {
+ version: 1,
+ agentTextToSpeech: true,
+ voicePreferences: ["pocket:mary"],
+ };
+ const settings = {
+ ...current,
+ voicePreferences: current.voicePreferences.includes(voiceKey)
+ ? ["pocket:mary"]
+ : current.voicePreferences,
+ };
+ if (activeConfig) {
+ activeConfig.mock ??= {};
+ activeConfig.mock.ttsSettings = settings;
+ }
+ return {
+ settings,
+ registry: await handleMockCommand("list_voice_registry", null),
+ };
+ }
case "get_builderlab_auth":
return activeConfig?.mock?.builderlabAuth ?? null;
case "start_builderlab_login": {
diff --git a/desktop/tests/e2e/voice-settings.spec.ts b/desktop/tests/e2e/voice-settings.spec.ts
index 490bee342..78756fb58 100644
--- a/desktop/tests/e2e/voice-settings.spec.ts
+++ b/desktop/tests/e2e/voice-settings.spec.ts
@@ -116,4 +116,43 @@ test.describe("Pocket voice settings", () => {
},
});
});
+
+ test("imports, selects, and safely deletes a local voice", async ({
+ page,
+ }) => {
+ await installMockBridge(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await openSettings(page, "voice");
+
+ await page.getByTestId("pocket-voice-import").click();
+ await expect(page.getByTestId("pocket-voice-selector")).toContainText(
+ "My voice",
+ );
+ await expect(page.getByTestId("pocket-voice-delete")).toBeVisible();
+
+ await page.getByTestId("pocket-voice-delete").click();
+ await expect(page.getByText("Delete imported voice?")).toBeVisible();
+ await page.getByTestId("confirm-pocket-voice-delete").click();
+ await expect(page.getByTestId("pocket-voice-selector")).toContainText(
+ "Mary",
+ );
+ await expect(page.getByTestId("pocket-voice-delete")).toBeHidden();
+
+ const mutations = await page.evaluate(() =>
+ (window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
+ .filter((entry) =>
+ ["import_pocket_voice", "delete_pocket_voice"].includes(
+ entry.command,
+ ),
+ )
+ .map((entry) => ({ command: entry.command, payload: entry.payload })),
+ );
+ expect(mutations).toEqual([
+ { command: "import_pocket_voice", payload: {} },
+ {
+ command: "delete_pocket_voice",
+ payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` },
+ },
+ ]);
+ });
});