diff --git a/apps/api/src/onboarding-intake.test.ts b/apps/api/src/onboarding-intake.test.ts index 42102f9..c0ca05b 100644 --- a/apps/api/src/onboarding-intake.test.ts +++ b/apps/api/src/onboarding-intake.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { closingNote, focusCard, + focusFactValue, intakeContext, intakeStep, nextIntakeStep, @@ -14,6 +15,13 @@ const plain = (text: string) => text.replace(/\s/g, " "); const off = { morning: null, evening: null, weekly: null }; describe("onboarding steps", () => { + it("writes focus facts in the user's locale", () => { + expect(focusFactValue("day", "en-US")).toBe("Wants help with day-to-day work first"); + expect(focusFactValue("day", "de-DE")).toBe( + "Möchte zuerst bei der täglichen Arbeit unterstützt werden", + ); + }); + it("asks in order and offers notifications only to a device that can still turn push on", () => { expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal"); expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm"); diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts index 1ed62a6..29ed239 100644 --- a/apps/api/src/onboarding-intake.ts +++ b/apps/api/src/onboarding-intake.ts @@ -48,8 +48,28 @@ export interface FocusOption { letter: string; label: string; apps: string[]; - /** Stored as a `goals` fact, so it stays a plain English statement. */ - fact: string; +} + +const FOCUS_FACTS: Record> = { + en: { + day: "Wants help with day-to-day work first", + inbox: "Wants help with inbox and email first", + research: "Wants help with research and writing first", + crew: "Wants a coordinated crew of specialist bots", + }, + de: { + day: "Möchte zuerst bei der täglichen Arbeit unterstützt werden", + inbox: "Möchte zuerst bei Posteingang und E-Mail unterstützt werden", + research: "Möchte zuerst bei Recherche und Schreiben unterstützt werden", + crew: "Möchte eine koordinierte Crew aus Spezialisten-Bots", + }, +}; + +/** Profile fact text for a focus answer, in the user's locale (defaults to English). */ +export function focusFactValue(focusId: string, locale: string): string { + const id = focusId as FocusOption["id"]; + const key = locale.toLowerCase().startsWith("de") ? "de" : "en"; + return FOCUS_FACTS[key][id] ?? FOCUS_FACTS.en[id]; } export const FOCUS_OPTIONS: FocusOption[] = [ @@ -58,28 +78,24 @@ export const FOCUS_OPTIONS: FocusOption[] = [ letter: "A", label: "Day-to-day work", apps: ["slack", "gmail", "googlecalendar"], - fact: "Wants help with day-to-day work first", }, { id: "inbox", letter: "B", label: "Inbox & email", apps: ["gmail", "googlecalendar", "slack"], - fact: "Wants help with inbox and email first", }, { id: "research", letter: "C", label: "Research & writing", apps: ["hackernews", "notion", "googledocs"], - fact: "Wants help with research and writing first", }, { id: "crew", letter: "D", label: "Specialist crew", apps: ["slack", "gmail", "googlecalendar"], - fact: "Wants a coordinated crew of specialist bots", }, ]; @@ -117,9 +133,16 @@ export const INTAKE_STEPS: IntakeStep[] = [ save: async ({ prisma, tx, userId }, answer) => { const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id); if (!option) return; + const profile = await tx.userProfile.findUnique({ + where: { userId }, + select: { locale: true, language: true }, + }); + const locale = intakeContext(profile, new Date()).locale; await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } }); await updateUserProfile(prisma, userId, { - addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }], + addFacts: [ + { category: "goals", value: focusFactValue(option.id, locale), source: "onboarding" }, + ], }); }, }, diff --git a/apps/web/e2e/empty-project-state.spec.ts b/apps/web/e2e/empty-project-state.spec.ts new file mode 100644 index 0000000..2a2a9b1 --- /dev/null +++ b/apps/web/e2e/empty-project-state.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from "@playwright/test"; +import { completeOnboarding, rpc, signup } from "./helpers"; + +test("empty project space shows Start before the main conversation exists", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project"); + await completeOnboarding(page); + + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Empty project" }); + await page.evaluate((spaceId) => { + window.localStorage.setItem("rakazo:space-id", spaceId); + }, side.id); + await page.goto("/app", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("project-start-main")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: /^Start$|^Starten$/i })).toBeVisible(); +}); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index b3e4036..9bb5bd3 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -320,3 +320,53 @@ export async function createNamedBot( await waitForChatComposer(page); return bot.id; } + +export const ALLOWED_CONSOLE_ERRORS = [ + /Failed to load resource.*404/i, + /favicon/i, + /ERR_ABORTED/i, + /Content Security Policy/i, + /net::ERR_/i, + /React Router Future Flag/i, + /DevTools failed to load SourceMap/i, +]; + +/** Collect unexpected console errors and page errors for the rest of the test. */ +export function attachConsoleGuard(page: Page) { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() !== "error") return; + const text = msg.text(); + if (ALLOWED_CONSOLE_ERRORS.some((pattern) => pattern.test(text))) return; + errors.push(text); + }); + page.on("pageerror", (err) => errors.push(err.message)); + return errors; +} + +/** True when the document does not scroll horizontally (±1px tolerance). */ +export async function assertNoHorizontalOverflow(page: Page) { + const ok = await page.evaluate(() => { + const doc = document.documentElement; + return doc.scrollWidth <= doc.clientWidth + 1; + }); + expect(ok, `horizontal overflow on ${page.url()}`).toBe(true); +} + +const MOBILE_TOUCH_MIN = 44; + +/** + * Primary chrome controls must meet 44×44px on touch viewports, either by box size or `rk-touch-target`. + */ +export async function assertPrimaryTouchTargets(page: Page, selectors: string[]) { + for (const selector of selectors) { + const locator = page.locator(selector).first(); + await expect(locator, selector).toBeVisible(); + const ok = await locator.evaluate((node, min) => { + const rect = node.getBoundingClientRect(); + if (rect.width >= min && rect.height >= min) return true; + return node.classList.contains("rk-touch-target"); + }, MOBILE_TOUCH_MIN); + expect(ok, `${selector} touch target`).toBe(true); + } +} diff --git a/apps/web/e2e/journey-console-errors.spec.ts b/apps/web/e2e/journey-console-errors.spec.ts index 171140c..baee547 100644 --- a/apps/web/e2e/journey-console-errors.spec.ts +++ b/apps/web/e2e/journey-console-errors.spec.ts @@ -1,39 +1,68 @@ import { expect, test } from "@playwright/test"; -import { completeOnboarding, openUserSettings, signup, waitForChatComposer } from "./helpers"; +import { + attachConsoleGuard, + completeOnboarding, + openUserSettings, + rpc, + signup, + waitForChatComposer, +} from "./helpers"; -const ALLOWED_CONSOLE = [ - /Failed to load resource.*404/i, - /favicon/i, - /ERR_ABORTED/i, - /Content Security Policy/i, - /net::ERR_/i, -]; +const SIGNED_IN_ROUTES = (groupId: string, botId: string) => + ["/app/heute", `/app/g/${groupId}`, `/app/${botId}`, "/onboarding"] as const; -function attachConsoleGuard(page: import("@playwright/test").Page) { - const errors: string[] = []; - page.on("console", (msg) => { - if (msg.type() !== "error") return; - const text = msg.text(); - if (ALLOWED_CONSOLE.some((pattern) => pattern.test(text))) return; - errors.push(text); - }); - page.on("pageerror", (err) => errors.push(err.message)); - return errors; +const SETTINGS_SECTIONS = [ + "general", + "integrations", + "models", + "memory", + "voice", + "notifications", + "usage", + "computer", + "updates", + "privacy", +] as const; + +async function exerciseSignedInRoutes(page: import("@playwright/test").Page, errors: string[]) { + const stamp = Date.now(); + await signup(page, `console-j-${stamp}@rakazo.test`, "password12", "Console QA"); + await completeOnboarding(page); + await waitForChatComposer(page); + + const groupId = page.url().match(/\/app\/g\/([^/]+)/)?.[1]; + const bots = await rpc>(page, "bots/list", {}); + const botId = bots[0]?.id; + expect(groupId).toBeTruthy(); + expect(botId).toBeTruthy(); + + for (const route of SIGNED_IN_ROUTES(groupId!, botId!)) { + await page.goto(route, { waitUntil: "domcontentloaded" }); + if (route !== "/onboarding") { + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 60_000, + }); + } + expect(errors, `after ${route}:\n${errors.join("\n")}`).toEqual([]); + } + + const settings = await openUserSettings(page); + for (const section of SETTINGS_SECTIONS) { + const nav = settings.getByTestId(`settings-nav-${section}`); + if (section !== "general") { + if (!(await nav.isVisible().catch(() => false))) continue; + await nav.click(); + } + expect(errors, `after settings ${section}:\n${errors.join("\n")}`).toEqual([]); + } } test.describe("desktop journeys", () => { test.use({ viewport: { width: 1440, height: 900 } }); - test("sign-up through settings has no unexpected console errors", async ({ page }) => { + test("signed-in route matrix has no unexpected console errors", async ({ page }) => { const errors = attachConsoleGuard(page); - const stamp = Date.now(); - await signup(page, `console-j-${stamp}@rakazo.test`, "password12", "Console QA"); - await completeOnboarding(page); - await waitForChatComposer(page); - await page.goto("/app/heute"); - await expect(page).toHaveURL(/\/app\/heute/); - await openUserSettings(page); - expect(errors, errors.join("\n")).toEqual([]); + await exerciseSignedInRoutes(page, errors); }); }); @@ -44,12 +73,8 @@ test.describe("mobile journeys", () => { hasTouch: true, }); - test("onboarding and chat shell stay console-clean", async ({ page }) => { + test("signed-in route matrix has no unexpected console errors", async ({ page }) => { const errors = attachConsoleGuard(page); - const stamp = Date.now(); - await signup(page, `console-m-${stamp}@rakazo.test`, "password12", "Mobile Console"); - await completeOnboarding(page); - await waitForChatComposer(page); - expect(errors, errors.join("\n")).toEqual([]); + await exerciseSignedInRoutes(page, errors); }); }); diff --git a/apps/web/e2e/mobile-layout-audit.spec.ts b/apps/web/e2e/mobile-layout-audit.spec.ts new file mode 100644 index 0000000..91dc597 --- /dev/null +++ b/apps/web/e2e/mobile-layout-audit.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; +import { + assertNoHorizontalOverflow, + assertPrimaryTouchTargets, + completeOnboarding, + openUserSettings, + rpc, + signup, + waitForChatComposer, +} from "./helpers"; + +test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + +test("main routes avoid horizontal overflow on a phone viewport", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `mobile-layout-${stamp}@rakazo.test`, "password12", "Mobile Layout"); + await completeOnboarding(page, undefined, { mainConversation: true }); + await waitForChatComposer(page); + + const groupMatch = page.url().match(/\/app\/g\/([^/]+)/); + const groupId = groupMatch?.[1]; + const bots = await rpc>(page, "bots/list", {}); + const botId = bots[0]?.id; + expect(groupId).toBeTruthy(); + expect(botId).toBeTruthy(); + + const routes = [`/app/heute`, `/app/g/${groupId}`, `/app/${botId}`]; + for (const route of routes) { + await page.goto(route, { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 60_000, + }); + await assertNoHorizontalOverflow(page); + } + + const settings = await openUserSettings(page); + for (const section of ["models", "memory", "voice", "notifications", "privacy"] as const) { + await settings.getByTestId(`settings-nav-${section}`).click(); + await assertNoHorizontalOverflow(page); + } +}); + +test("primary shell controls meet touch target guidance", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `mobile-touch-${stamp}@rakazo.test`, "password12", "Mobile Touch"); + await completeOnboarding(page); + await waitForChatComposer(page); + + await assertPrimaryTouchTargets(page, [ + "[data-testid='user-menu-trigger']", + "[data-testid='create-menu-trigger']", + "[data-testid='open-navigation']", + ]); +}); diff --git a/apps/web/e2e/onboarding-intake-de.spec.ts b/apps/web/e2e/onboarding-intake-de.spec.ts index 4f2ad93..2d4bb0f 100644 --- a/apps/web/e2e/onboarding-intake-de.spec.ts +++ b/apps/web/e2e/onboarding-intake-de.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { completeOnboarding, signup } from "./helpers"; +import { completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); @@ -11,6 +11,7 @@ test("German UI localizes goal placeholder and question in intake", async ({ pag const stamp = Date.now(); await signup(page, `intake-de-${stamp}@rakazo.test`, "password12", "Deutsch Intake"); await completeOnboarding(page, undefined, { mainConversation: true }); + await rpc(page, "profile/update", { locale: "de-DE", language: "de" }); await page.getByTestId("focus-choice-day").click(); const goal = page.locator('[data-testid="choice-card"][data-step="goal"]'); @@ -20,4 +21,10 @@ test("German UI localizes goal placeholder and question in intake", async ({ pag name: "Was möchtest du in den nächsten Wochen erledigt bekommen?", }), ).toHaveAttribute("placeholder", "Dein Ziel"); + + const settings = await openUserSettings(page); + const profile = settings.getByTestId("user-profile-settings"); + await expect(profile.getByTestId("user-facts-goals")).toContainText( + "Möchte zuerst bei der täglichen Arbeit unterstützt werden", + ); }); diff --git a/apps/web/e2e/route-deep-link-matrix.spec.ts b/apps/web/e2e/route-deep-link-matrix.spec.ts new file mode 100644 index 0000000..b32c1a3 --- /dev/null +++ b/apps/web/e2e/route-deep-link-matrix.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from "@playwright/test"; +import { + attachConsoleGuard, + completeOnboarding, + rpc, + signup, + waitForChatComposer, +} from "./helpers"; + +const mobile = { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }; + +test.describe("signed out deep links", () => { + test.use(mobile); + + for (const { path, expectUrl, expectText } of [ + { path: "/", expectUrl: /\/($|\?)/, expectText: /evOo|Willkommen|Welcome/i }, + { path: "/sign-in", expectUrl: /\/sign-in/, expectText: /Sign in to evOo|Bei evOo anmelden/i }, + { path: "/sign-up", expectUrl: /\/sign-up/, expectText: /Create account|Konto erstellen/i }, + { path: "/forgot-password", expectUrl: /\/forgot-password/, expectText: /password|Passwort/i }, + { path: "/onboarding", expectUrl: /\/sign-in/, expectText: /Sign in|Anmelden/i }, + { path: "/app/heute", expectUrl: /\/sign-in/, expectText: /Sign in|Anmelden/i }, + { path: "/app/g/example-group", expectUrl: /\/sign-in/, expectText: /Sign in|Anmelden/i }, + { path: "/app/example-bot", expectUrl: /\/sign-in/, expectText: /Sign in|Anmelden/i }, + ]) { + test(`load ${path} without console errors`, async ({ page }) => { + const errors = attachConsoleGuard(page); + await page.goto(path, { waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(expectUrl); + await expect(page.locator("body")).toContainText(expectText); + expect(errors, errors.join("\n")).toEqual([]); + }); + } +}); + +test.describe("signed in reload matrix", () => { + test.use(mobile); + + test("core app routes reload cleanly", async ({ page }) => { + const errors = attachConsoleGuard(page); + const stamp = Date.now(); + await signup(page, `deeplink-${stamp}@rakazo.test`, "password12", "Deep Link"); + await completeOnboarding(page, undefined, { mainConversation: true }); + await waitForChatComposer(page); + + const groupId = page.url().match(/\/app\/g\/([^/]+)/)?.[1]; + const bots = await rpc>(page, "bots/list", null); + const botId = bots[0]?.id; + expect(groupId).toBeTruthy(); + expect(botId).toBeTruthy(); + + const routes = [`/app/heute`, `/app/g/${groupId}`, `/app/${botId}`, `/onboarding`]; + for (const route of routes) { + await page.goto(route, { waitUntil: "domcontentloaded" }); + if (route === "/onboarding") { + await expect(page).toHaveURL(/\/(app|onboarding)/); + } else { + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 60_000, + }); + } + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator("body")).not.toBeEmpty(); + expect(errors, errors.join("\n")).toEqual([]); + } + }); +}); diff --git a/apps/web/e2e/session-unavailable.spec.ts b/apps/web/e2e/session-unavailable.spec.ts new file mode 100644 index 0000000..43eb1b2 --- /dev/null +++ b/apps/web/e2e/session-unavailable.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; +import { completeOnboarding, signup } from "./helpers"; + +test("shows SessionUnavailable when auth session cannot be loaded", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `session-down-${stamp}@rakazo.test`, "password12", "Session Down"); + await completeOnboarding(page); + + await page.route("**/api/auth/get-session", (route) => route.abort("failed")); + await page.goto("/app/heute", { waitUntil: "domcontentloaded" }); + await expect(page.getByText(/Can't reach the server|Server nicht erreichbar/i)).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByRole("button", { name: /Retry now|Jetzt erneut versuchen/i }), + ).toBeVisible(); +}); diff --git a/apps/web/e2e/teaching-composer.spec.ts b/apps/web/e2e/teaching-composer.spec.ts new file mode 100644 index 0000000..a6f0f27 --- /dev/null +++ b/apps/web/e2e/teaching-composer.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; +import { activeBotId, completeOnboarding, signup, waitForChatComposer } from "./helpers"; + +test("blocks the composer while a skill is being taught", async ({ page }) => { + const stamp = Date.now(); + await signup(page, `teach-composer-${stamp}@rakazo.test`, "password12", "Teach Composer"); + await completeOnboarding(page); + await waitForChatComposer(page); + + await page.getByTitle("Agent computer").click(); + const sidePanel = page.getByTestId("side-panel"); + await expect(sidePanel).toHaveAttribute("data-panel", "computer"); + const openButton = sidePanel.getByTestId("computer-preview-open"); + await expect(openButton).toBeVisible(); + await openButton.click(); + const chrome = page.getByTestId("computer-chrome"); + await chrome.getByTestId("teach-start-button").click(); + await page.getByTestId("teach-goal-input").fill("Export weekly list"); + await page.getByRole("button", { name: "Start recording" }).click(); + await expect(page.getByTestId("teach-recording-overlay")).toBeVisible(); + + await expect( + page.getByText( + /Teaching in progress\. Stop teaching before sending a new message|Aufgabe wird angelernt\. Beende das Anlernen, bevor du eine neue Nachricht sendest\./i, + ), + ).toBeVisible(); + const composer = page.getByRole("combobox", { name: /^Message$|^Nachricht$/i }); + await expect(composer).toBeDisabled(); + expect(activeBotId(page)).toBeTruthy(); +}); diff --git a/apps/web/index.html b/apps/web/index.html index a3c98da..2bd5e9f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,7 @@ - + diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index 1ad79a4..9379b9b 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -158,9 +158,9 @@ export function HeuteBrief({ return (
-
-
-

+
+
+

{dateLabel}

@@ -171,7 +171,9 @@ export function HeuteBrief({ />

- +
+ +
diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index d377ba4..7ce6742 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -2717,7 +2717,7 @@ export function ShellPage() {