evOo-local/apps/web/e2e/voice.spec.ts
cynarAI de95cf1b91
Fix red main: typecheck, lint, i18n, unit and web E2E (#222)
* Fix red main: typecheck, lint, i18n and stale unit tests

Direct pushes to main left typecheck, lint and 16 unit tests red.

- Typecheck: AskCard test uses the current ask block shape, the sandbox
  fault test calls the two-argument desktop writeFile, removing a queued
  message notifies with the thread's latest event seq, and the replay
  fixture only reads ref from steps that carry one.
- Lint: format and import order.
- i18n: transcript status labels were German source strings; they are
  English now with German translations, catalogs re-extracted, and the
  mobile "Failed to remove message" string is translated.
- Tests follow intended behavior changes: the time instruction now
  closes the system prompt, short chat turns start at low reasoning,
  owner-owned read-only files (git objects) pass the home check, and the
  desktop computer may read and write host files outside its home. The
  old desktop write-containment suite is replaced by a host file test.
- The composer task pill test mocks the Lingui macros like other tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Bump Expo patch releases so expo install --check passes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Bring web E2E back in line with the new side panel

The side panel lost its computer tab, which left the full computer window
(take control, teach, release) unreachable from the web app. The browser
view now offers an "Open in full window" button, and opening the panel no
longer boots a stopped computer.

Also: the slash picker scrolls instead of growing under the header, user
skills come first in it, the agent overview hides an empty routines block,
the hover time hides again with collapsed details, and the light destructive
token meets contrast on the sidebar. E2E specs follow the new panel layout,
and the cursor and hover tests no longer race the UI.

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>
2026-10-22 21:58:24 +02:00

145 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);
// The app turns the local voice on by itself the first time it starts.
const speaking = voice.getByRole("switch", { name: "Speaking on or off" });
if ((await speaking.getAttribute("aria-checked")) !== "true") await speaking.click();
await expect(speaking).toHaveAttribute("aria-checked", "true");
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");
});