Compare commits

...

10 Commits

Author SHA1 Message Date
Cursor Agent
490e2fefe8
Format Shell after Heute route helper
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:41:46 +00:00
Cursor Agent
83ec966a67
Harden Heute route and German voice onboarding E2E
Rebased onto main with Expo SDK 57 typecheck fixes. Voice options expose
stable test ids; Shell keeps /app/heute across bootstrap and redirects
legacy /app/heute bot URLs. Heute coach E2E asserts URL after reload.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:01:08 +00:00
Cursor Agent
057e039e83
Relax German voice E2E name matching for voice labels
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:12 +00:00
Cursor Agent
6b6e34c1a6
Fix Heute bootstrap redirect and German onboarding voices
When Kokoro lists no locale-matched voices, onboarding now uses offline
presets instead of showing English-only options. Shell bootstrap skips
bot redirects while the user is on /app/heute.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:12 +00:00
Cursor Agent
628b008e2a
Stabilize Heute reload URL and German voice E2E assertions
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:12 +00:00
Cursor Agent
7f35ac1aa9
Fix German E2E: Nachricht composer, voice loading wait, Heute rhythm
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:11 +00:00
Cursor Agent
2d5bb7ea35
Use language-step Skip · locale button in completeOnboarding
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:11 +00:00
Cursor Agent
b0ec12ec6c
Fix completeOnboarding language skip for Skip · locale label
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:11 +00:00
Cursor Agent
1511880f1b
Make onboarding E2E helpers locale-aware for German UI
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:11 +00:00
Cursor Agent
c5b2ebc2b3
Fix German signup E2E, Escape menu stacking, and Heute rhythm click
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 17:00:11 +00:00
9 changed files with 127 additions and 42 deletions

View File

@ -307,6 +307,10 @@ test("group chats share every context-menu action", async ({ page }, testInfo) =
await group.click({ button: "right" });
await captureScreenshot(page, testInfo, "group-context-menu-mobile");
await page.keyboard.press("Escape");
if ((await page.getByRole("menu").count()) > 0) {
await page.keyboard.press("Escape");
}
await expect(page.getByRole("menu")).toHaveCount(0);
await page.setViewportSize({ width: 1280, height: 800 });
await group.click({ button: "right" });

View File

@ -5,12 +5,15 @@ test("onboarding names the first Oo and shows it in chat", async ({ page }) => {
const stamp = Date.now();
await signup(page, `name-oo-${stamp}@rakazo.test`, "password12", "Namer");
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await page.getByTestId("onboarding-oo-name-input").fill("Luna");
await page.getByTestId("onboarding-name-step").getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: /Skip/i }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page
.getByTestId("onboarding-name-step")
.getByRole("button", { name: /^(Continue|Weiter)$/i })
.click();
await page.getByRole("button", { name: /^(Skip|Überspringen ·)/i }).click();
await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
await expect(page).toHaveURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
await expect(
page.getByTestId("bot-settings-trigger").getByText("Luna", { exact: true }),
@ -25,8 +28,8 @@ test("German UI keeps Deutsch on language skip label", async ({ page }) => {
});
const stamp = Date.now();
await signup(page, `de-skip-${stamp}@rakazo.test`, "password12", "Deutsch");
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
await expect(
page.getByRole("heading", { name: /Wähle deine Sprache|Choose your language/i }),
).toBeVisible();
@ -40,15 +43,16 @@ test("German voice step lists German voices after choosing Deutsch", async ({ pa
});
const stamp = Date.now();
await signup(page, `de-voice-${stamp}@rakazo.test`, "password12", "Stimme");
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
await page.getByRole("button", { name: /^(Skip|Überspringen)$/i }).click();
await page.getByRole("button", { name: "Deutsch", exact: true }).click();
await expect(page.getByRole("button", { name: "Eva", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Alloy", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Eva", exact: true })).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(page.getByText(/Loading voices|Stimmen werden geladen/i)).toHaveCount(0, {
timeout: 30_000,
});
await expect(page.getByTestId("onboarding-voice-df_eva")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("onboarding-voice-dm_bernd")).toBeVisible();
await expect(page.getByTestId("onboarding-voice-af_alloy")).toHaveCount(0);
await expect(page.getByTestId("onboarding-voice-df_eva")).toHaveAttribute("aria-pressed", "true");
});
test("main conversation does not show a group subtitle for one Oo", async ({ page }) => {

View File

@ -87,10 +87,14 @@ async function visible(locator: Locator, timeout: number) {
return locator.isVisible({ timeout }).catch(() => false);
}
const onboardingContinueButton = /^(Continue|Weiter)$/i;
const onboardingSkipButton = /^(Skip|Überspringen)$/i;
const onboardingLanguageSkipButton = /^(Skip|Überspringen) ·/i;
/** After the look picker, skip the optional Oo naming step (keeps the default name). */
export async function skipOnboardingNameStep(page: Page) {
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.getByRole("button", { name: onboardingSkipButton }).click();
}
/**
@ -113,11 +117,15 @@ export async function completeOnboarding(
);
}
const design = page.getByRole("heading", { name: /Design your Oo/i });
const design = page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i });
const naming = page.getByTestId("onboarding-name-step");
const language = page.getByRole("heading", { name: /Choose your language/i });
const voice = page.getByRole("heading", { name: /Pick a voice/i });
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
const language = page.getByRole("heading", {
name: /Choose your language|Wähle deine Sprache/i,
});
const voice = page.getByRole("heading", { name: /Pick a voice|Wähle eine Stimme/i });
const integrations = page.getByRole("heading", {
name: /Server integrations|Server-Integrationen/i,
});
const expectedOoName = options.ooName ?? "Chief";
const chief = page.getByText(expectedOoName).first();
@ -127,30 +135,30 @@ export async function completeOnboarding(
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) {
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: onboardingContinueButton }).click();
await expect(naming).toBeVisible();
if (options.ooName) {
await page.getByTestId("onboarding-oo-name-input").fill(options.ooName);
await naming.getByRole("button", { name: "Continue", exact: true }).click();
await naming.getByRole("button", { name: onboardingContinueButton }).click();
} else {
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.getByRole("button", { name: onboardingSkipButton }).click();
}
await expect(language).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();
await page.getByRole("button", { name: onboardingLanguageSkipButton }).click();
await expect(voice).toBeVisible();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.getByRole("button", { name: onboardingSkipButton }).click();
}
if (await visible(integrations, 8_000)) {
if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps");
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.getByRole("button", { name: onboardingSkipButton }).click();
}
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
// then lands in the main conversation.
const skipScan = page
.locator('[data-onboarding-step="scan"]')
.getByRole("button", { name: "Skip", exact: true });
.getByRole("button", { name: onboardingSkipButton });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
@ -186,15 +194,17 @@ export async function signup(
) {
await expect(async () => {
await page.goto("/sign-up", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Create your evOo" })).toBeVisible({
await expect(
page.getByRole("heading", { name: /Create your evOo|Dein evOo erstellen/i }),
).toBeVisible({
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up");
await page.getByPlaceholder("Your name").fill(name);
await page.getByPlaceholder("Your email address").fill(email);
await page.getByPlaceholder("Password").fill(password);
await page.getByRole("button", { name: "Create account" }).click();
await page.getByPlaceholder(/Your name|Dein Name/i).fill(name);
await page.getByPlaceholder(/Your email address|Deine E-Mail-Adresse/i).fill(email);
await page.getByPlaceholder(/^Password$|^Passwort$/i).fill(password);
await page.getByRole("button", { name: /Create account|Konto erstellen/i }).click();
}
export async function captureScreenshot(page: Page, testInfo: TestInfo, name: string) {
@ -289,7 +299,7 @@ export async function waitForChatComposer(page: Page) {
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 30_000,
});
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByRole("combobox", { name: /^Message$|^Nachricht$/i })).toBeVisible();
}
/** Create a named bot via RPC for test setup (skips the + picker). */

View File

@ -84,13 +84,21 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days");
await captureScreenshot(page, testInfo, "heute-coach");
await page.reload();
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/app\/heute$/);
await expect(page.getByTestId("heute-brief")).toBeVisible();
await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked();
// Check-in times live behind one compact control.
await page.getByTestId("heute-rhythm").click();
const rhythm = page.getByTestId("heute-rhythm");
await expect(page.getByTestId("heute-coach-brief")).toBeVisible();
await rhythm.scrollIntoViewIfNeeded();
await expect(rhythm).toBeVisible({ timeout: 30_000 });
const rhythmLoaded = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/rhythm/get") && response.ok(),
);
await rhythm.click();
await rhythmLoaded;
const panel = page.getByTestId("heute-rhythm-panel");
await expect(panel).toBeVisible();
const morning = panel.getByTestId("heute-rhythm-morning");

View File

@ -1,9 +1,12 @@
import { Trans } from "@lingui/react/macro";
import type { VoiceInfo } from "@rakazo/contracts";
import { voicesForLocale } from "@rakazo/core";
import { Button } from "@rakazo/ui-web";
import { useEffect, useRef, useState } from "react";
import { defaultOnboardingVoiceId, offlineOnboardingVoices } from "../../lib/onboarding-voices";
import {
defaultOnboardingVoiceId,
offlineOnboardingVoices,
onboardingVoicesFromList,
} from "../../lib/onboarding-voices";
import { rpc } from "../../lib/rpc";
import type { UiLocale } from "../../lib/ui-locale";
@ -39,7 +42,7 @@ export function OnboardingVoiceStep({
await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN });
const listed = await rpc.voice.voices({ provider: "kokoro" });
if (cancelled) return;
const ordered = voicesForLocale(listed, uiLocale).slice(0, 6);
const ordered = onboardingVoicesFromList(listed, uiLocale);
setVoiceOptions(ordered);
const nextId = defaultOnboardingVoiceId(uiLocale, ordered);
if (nextId) onSelectedVoiceIdChange(nextId);
@ -89,6 +92,7 @@ export function OnboardingVoiceStep({
<button
key={voice.id}
type="button"
data-testid={`onboarding-voice-${voice.id}`}
onClick={() => onSelectedVoiceIdChange(voice.id)}
aria-pressed={selectedVoiceId === voice.id}
className={`rounded-xl border-2 px-3 py-3 text-left text-sm font-medium transition-all ${

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { defaultOnboardingVoiceId, offlineOnboardingVoices } from "./onboarding-voices.js";
import {
defaultOnboardingVoiceId,
offlineOnboardingVoices,
onboardingVoicesFromList,
} from "./onboarding-voices.js";
describe("offlineOnboardingVoices", () => {
it("offers German voices for de", () => {
@ -18,6 +22,18 @@ describe("offlineOnboardingVoices", () => {
});
});
describe("onboardingVoicesFromList", () => {
it("uses offline German presets when the server only lists English voices", () => {
const englishOnly = [{ id: "af_alloy", label: "Alloy", description: "neutral" }];
expect(onboardingVoicesFromList(englishOnly, "de").map((voice) => voice.id)).toEqual([
"df_eva",
"dm_bernd",
"df_victoria",
"dm_martin",
]);
});
});
describe("defaultOnboardingVoiceId", () => {
it("prefers df_eva for German", () => {
expect(defaultOnboardingVoiceId("de", offlineOnboardingVoices("de"))).toBe("df_eva");

View File

@ -13,11 +13,41 @@ const ENGLISH_ONBOARDING_VOICES: VoiceInfo[] = [
const OFFLINE_CATALOG = [...GERMAN_VOICE_PRESETS, ...ENGLISH_ONBOARDING_VOICES];
const KOKORO_LANGUAGE_PREFIXES: Record<string, string> = {
de: "d",
en: "ab",
es: "e",
fr: "f",
hi: "h",
it: "i",
ja: "j",
pt: "p",
zh: "z",
};
function listedVoicesSpeakLocale(listed: VoiceInfo[], locale: string): boolean {
const language = locale.toLowerCase().split("-")[0] ?? "";
const prefixes = KOKORO_LANGUAGE_PREFIXES[language];
if (!prefixes) return listed.length > 0;
return listed.some((voice) => {
const prefix = /^([a-z])[fm]_/.exec(voice.id)?.[1];
return Boolean(prefix && prefixes.includes(prefix));
});
}
/** Locale-aware voice choices for onboarding when listing from the server fails. */
export function offlineOnboardingVoices(locale: string): VoiceInfo[] {
return voicesForLocale(OFFLINE_CATALOG, locale).slice(0, 6);
}
/** Prefer server voices that speak the UI language; otherwise use offline presets. */
export function onboardingVoicesFromList(listed: VoiceInfo[], locale: string): VoiceInfo[] {
if (listedVoicesSpeakLocale(listed, locale)) {
return voicesForLocale(listed, locale).slice(0, 6);
}
return offlineOnboardingVoices(locale);
}
export function defaultOnboardingVoiceId(locale: string, voices: VoiceInfo[]): string {
return defaultVoiceForLocale(voices, locale)?.id ?? "";
}

View File

@ -273,6 +273,11 @@ export function ShellPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const onHeuteRoute = isHeuteAppPath(location.pathname);
useEffect(() => {
if (botId === "heute") {
navigate(HEUTE_APP_PATH, { replace: true });
}
}, [botId, navigate]);
const [activeNavPillar, setActiveNavPillar] = useState<NavPillar>(() => {
if (isHeuteAppPath(location.pathname)) return "heute";
if (groupId) return "projekte";
@ -620,8 +625,11 @@ export function ShellPage() {
const recordingSkill = activeTaughtSkills.find((skill) => skill.status === "recording") ?? null;
const routeBotId = useRef<string | undefined>(botId);
routeBotId.current = botId;
const routePathname = useRef(location.pathname);
routePathname.current = location.pathname;
const routeOnHeute = useRef(onHeuteRoute);
routeOnHeute.current = onHeuteRoute;
const holdingHeuteRoute = () => routeOnHeute.current || isHeuteAppPath(routePathname.current);
const routeGroupId = useRef<string | undefined>(groupId);
routeGroupId.current = groupId;
const activeBotId = useRef<string | undefined>(inGroup ? undefined : active?.id);
@ -756,7 +764,7 @@ export function ShellPage() {
return;
}
if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) {
if (routeOnHeute.current) return;
if (holdingHeuteRoute()) return;
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
}
} finally {
@ -1004,9 +1012,11 @@ export function ShellPage() {
return;
}
if (inProject && !botId) {
if (holdingHeuteRoute()) return;
navigate(firstThreadRoute(bootstrap.bots, groupList, true), { replace: true });
return;
}
if (holdingHeuteRoute()) return;
const selectedBotId = bootstrap.thread?.botId ?? bootstrap.bots[0]?.id;
if (selectedBotId && selectedBotId !== botId) {
navigate(`/app/${selectedBotId}`, { replace: true });

View File

@ -22,14 +22,13 @@ export function useMobileSidebar() {
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape" || window.matchMedia("(min-width: 768px)").matches) return;
event.preventDefault();
event.stopPropagation();
setMobileSidebarOpen(false);
queueMicrotask(() => {
document.querySelector<HTMLElement>('[data-testid="open-navigation"]')?.focus();
});
}
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [mobileSidebarOpen]);
function onMobileSidebarTouchStart(event: React.TouchEvent<HTMLDivElement>) {