evOo-local/apps/web/e2e/voice.spec.ts
Claude bc7353f1f2
Pick the voice from a carousel in the app language, local voice only
Voice settings now list only the voices that speak the app language
(German shows German voices, English shows English ones) as a horizontal
carousel: one card per voice, a play button next to the name, tap to
make it the active voice. Changing the app language moves the saved
voice to that language. The hosted voice providers (ElevenLabs, OpenAI,
Cartesia, Fish Audio) are removed; Kokoro on the server is the only
voice. E2E runs against a stub Kokoro server so the carousel is covered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57
2026-10-05 14:44:03 +00:00

147 lines
6.3 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
async function turnOnSpeaking(page: Page) {
const settings = await openUserSettings(page, "voice");
const voice = settings.getByTestId("voice-settings");
await expect(voice).toBeVisible();
await expect(voice.getByRole("button", { name: "Other voice providers" })).toHaveCount(0);
await expect(voice.getByLabel("API key", { exact: true })).toHaveCount(0);
await voice.getByRole("switch", { name: "Speaking on or off" }).click();
await expect(voice.getByTestId("voice-carousel")).toBeVisible();
return voice;
}
test("voice settings pick a voice from the carousel, speak a reply, and open a call", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `voice-${stamp}@rakazo.test`, "password12", `Voice ${stamp}`);
await completeOnboarding(page);
await expect(page.getByRole("button", { name: "Call", exact: true })).toHaveCount(0);
await expect(
page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }),
).toHaveCount(1);
await captureScreenshot(page, testInfo, "voice-composer");
const preparedOff = await rpc<{ ready: boolean }>(page, "voice/prepare", {
text: "Hello there.",
});
expect(preparedOff.ready).toBe(false);
const voice = await turnOnSpeaking(page);
const cards = voice.getByTestId("voice-card");
// English UI lists only the English voices of the local server.
await expect(cards.filter({ hasText: "Heart" })).toHaveCount(1);
await expect(cards.filter({ hasText: "George" })).toHaveCount(1);
await expect(cards.filter({ hasText: "Eva" })).toHaveCount(0);
await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await voice.getByRole("button", { name: "Bella", exact: true }).click();
await expect(voice.getByRole("button", { name: "Bella", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
"aria-pressed",
"false",
);
await expect
.poll(async () => (await rpc<{ voiceId: string }>(page, "voice/status", {})).voiceId)
.toBe("af_bella");
const previewSpoken = page.waitForResponse(
(response) => response.url().includes("/api/voice/speak") && response.ok(),
);
await voice.getByRole("button", { name: "Play Sky", exact: true }).click();
await previewSpoken;
// A preview only plays the voice; the chosen one stays.
await expect(voice.getByRole("button", { name: "Bella", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await captureScreenshot(page, testInfo, `voice-settings-carousel-${colorScheme}`);
}
await page.emulateMedia({ colorScheme: "light" });
await page.getByRole("button", { name: "Close voice settings" }).click();
await expect(page.getByTestId("user-settings")).toHaveCount(0);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("say hello");
await expect(
page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }),
).toHaveCount(0);
await captureScreenshot(page, testInfo, "voice-composer-typing");
await page.keyboard.press("Enter");
// A reply can render more than one text bubble; speak the latest one.
const speakReply = page.getByRole("button", { name: "Speak this reply" }).last();
await expect(speakReply).toBeVisible({ timeout: 30_000 });
const replySpoken = page.waitForResponse(
(response) => response.url().includes("/api/voice/speak") && response.ok(),
);
await speakReply.click();
await replySpoken;
await page
.getByTestId("composer-bar")
.getByRole("button", { name: "Voice", exact: true })
.click();
await expect(page.getByTestId("call-view")).toBeVisible();
await expect(page.getByTestId("voice-panel")).toBeVisible();
await expect(page.getByRole("button", { name: "Hang up" })).toBeVisible();
await captureScreenshot(page, testInfo, "voice-stage");
await page.getByRole("button", { name: "Hang up" }).click();
await expect(page.getByTestId("call-view")).toHaveCount(0);
});
test("German lists only German voices and switches the saved voice to one", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `voice-de-${stamp}@rakazo.test`, "password12", `Stimme ${stamp}`);
await completeOnboarding(page);
let voice = await turnOnSpeaking(page);
await expect(voice.getByRole("button", { name: "Heart", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await page.getByRole("button", { name: "Close voice settings" }).click();
// Switching the app language moves the saved voice to that language too.
const settings = await openUserSettings(page);
await settings.getByTestId("ui-locale-select").click();
await settings.getByRole("option", { name: "Deutsch", exact: true }).click();
await expect(settings.getByRole("heading", { name: "Sprache", exact: true })).toBeVisible();
await expect
.poll(async () => (await rpc<{ voiceId: string }>(page, "voice/status", {})).voiceId)
.toBe("df_eva");
await settings.getByTestId("settings-nav-voice").click();
voice = settings.getByTestId("voice-settings");
const cards = voice.getByTestId("voice-card");
await expect(cards.filter({ hasText: "Eva" })).toHaveCount(1);
await expect(cards.filter({ hasText: "Bernd" })).toHaveCount(1);
await expect(cards.filter({ hasText: "Heart" })).toHaveCount(0);
await expect(voice.getByRole("button", { name: "Eva", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(voice.getByRole("button", { name: "Bernd anhören", exact: true })).toBeVisible();
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await captureScreenshot(page, testInfo, `voice-settings-carousel-de-${colorScheme}`);
}
await page.emulateMedia({ colorScheme: "light" });
await page.setViewportSize({ width: 390, height: 844 });
await expect(voice.getByTestId("voice-carousel")).toBeVisible();
await captureScreenshot(page, testInfo, "voice-settings-carousel-de-phone");
});