* Stop two web E2E tests from racing on main Replies read themselves aloud by default since the voice update, so in the group chat test the last bot's automatic read-aloud could beat the manual Speak click. The test turns automatic reading off for its two bots. Opening the browser panel on a running computer now only fetches the screen, as it did before the panel change, instead of also reloading the whole thread on every computer state change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Wait for the speaking switch to settle in the voice test The app turns the local voice on by itself on first start and keeps the switch disabled meanwhile. The helper read "off" during that window and clicked a disabled switch; it now retries until the switch is on, clicking only when it is enabled and off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Fix main after the git-clone push: lint, i18n and abort tests - Lint: formatting in two files and no assignment inside an expression in the steering poller. - i18n: German for the new "Resume" and "Stopped" labels on web, and de/ru/zh on mobile; catalogs re-extracted. - Tests follow the intended change that a caller abort ends a turn as "stopped" instead of failing it as a provider error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Record runtime failures again instead of leaving the run running A runtime error closes the stream through withRuntimeCleanup, which aborts the run's own controller. The catch treated that as an outside stop and returned without finalizing, so a failed run stayed running forever and its delegated task never reported back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 --------- Co-authored-by: Claude <noreply@anthropic.com>
150 lines
6.6 KiB
TypeScript
150 lines
6.6 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);
|
|
// The app turns the local voice on by itself the first time it starts, and the switch stays
|
|
// disabled while that runs; only click once it settled off.
|
|
const speaking = voice.getByRole("switch", { name: "Speaking on or off" });
|
|
await expect(async () => {
|
|
if ((await speaking.getAttribute("aria-checked")) !== "true" && (await speaking.isEnabled())) {
|
|
await speaking.click();
|
|
}
|
|
await expect(speaking).toHaveAttribute("aria-checked", "true", { timeout: 3_000 });
|
|
}).toPass({ timeout: 30_000 });
|
|
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 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("dm_thorsten");
|
|
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: "Thorsten" })).toHaveCount(1);
|
|
await expect(cards.filter({ hasText: "Heart" })).toHaveCount(0);
|
|
await expect(voice.getByRole("button", { name: "Thorsten", exact: true })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
await expect(voice.getByRole("button", { name: "Eva 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");
|
|
});
|