From e4b995ec14496878c2e9e11a1c17513734e2b301 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:26:09 +0200 Subject: [PATCH] Production readiness Phase 2: DE profile facts, mobile audit, route matrix E2E (#176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): Phase 2 production readiness — locale, mobile, E2E matrix Localize onboarding focus facts by profile locale (DE assertion). Add mobile overflow/touch audits, deep-link matrix, extended console checks, and E2E for empty project, session unavailable, and teaching composer. Safe-area and Heute header layout on 390px viewports. Co-authored-by: cynarAI * fix(e2e): stabilize Phase 2 specs for mobile shell and empty project Co-authored-by: cynarAI * fix(api): satisfy typecheck for focusFactValue Co-authored-by: cynarAI * fix(api): type-safe focusFactValue lookup Co-authored-by: cynarAI * fix(api): narrow focus fact locale key Co-authored-by: cynarAI * fix(api): use FocusOptionId union for fact lookup Co-authored-by: cynarAI * test(web): align Shell header control assertions with touch targets Co-authored-by: cynarAI * style(web): format desktop header control test Co-authored-by: cynarAI * fix(web): stabilize Phase 2 E2E (settings i18n, empty project, chart plot load) Co-authored-by: cynarAI * fix(web): empty project E2E simulates pre-start bootstrap; relax module recovery wait Co-authored-by: cynarAI * fix(web): format empty-project E2E; stabilize chart-links cold Plot load Co-authored-by: cynarAI * test(web): run chart-links E2E serially to reuse warmed Plot module Co-authored-by: cynarAI * style(web): format chart-links E2E Co-authored-by: cynarAI --------- Co-authored-by: Cursor Agent Co-authored-by: cynarAI --- apps/api/src/onboarding-intake.test.ts | 8 + apps/api/src/onboarding-intake.ts | 45 ++++- apps/web/e2e/chart-links.spec.ts | 172 +++++++++--------- apps/web/e2e/empty-project-state.spec.ts | 47 +++++ apps/web/e2e/helpers.ts | 63 ++++++- apps/web/e2e/journey-console-errors.spec.ts | 91 +++++---- apps/web/e2e/mobile-layout-audit.spec.ts | 54 ++++++ apps/web/e2e/module-recovery.spec.ts | 8 +- apps/web/e2e/onboarding-intake-de.spec.ts | 9 +- apps/web/e2e/route-deep-link-matrix.spec.ts | 66 +++++++ apps/web/e2e/session-unavailable.spec.ts | 17 ++ apps/web/e2e/teaching-composer.spec.ts | 30 +++ apps/web/index.html | 2 +- apps/web/src/lib/desktop.test.ts | 9 +- apps/web/src/pages/HeuteBrief.tsx | 10 +- apps/web/src/pages/Shell.tsx | 4 +- .../pages/shell/sidebar-toggle-buttons.tsx | 2 +- docs/PRODUCTION-READINESS.md | 78 ++++---- 18 files changed, 538 insertions(+), 177 deletions(-) create mode 100644 apps/web/e2e/empty-project-state.spec.ts create mode 100644 apps/web/e2e/mobile-layout-audit.spec.ts create mode 100644 apps/web/e2e/route-deep-link-matrix.spec.ts create mode 100644 apps/web/e2e/session-unavailable.spec.ts create mode 100644 apps/web/e2e/teaching-composer.spec.ts 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..d0bf6c5 100644 --- a/apps/api/src/onboarding-intake.ts +++ b/apps/api/src/onboarding-intake.ts @@ -43,13 +43,39 @@ interface IntakeStep { save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise; } +export type FocusOptionId = "day" | "inbox" | "research" | "crew"; + export interface FocusOption { - id: string; + id: FocusOptionId; letter: string; label: string; apps: string[]; - /** Stored as a `goals` fact, so it stays a plain English statement. */ - fact: string; +} + +const FOCUS_FACTS = { + 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", + }, +} as const satisfies Record>; + +/** Profile fact text for a focus answer, in the user's locale (defaults to English). */ +function isFocusOptionId(id: string): id is FocusOptionId { + return FOCUS_OPTIONS.some((option) => option.id === id); +} + +export function focusFactValue(focusId: string, locale: string): string { + if (!isFocusOptionId(focusId)) return focusId; + const key: keyof typeof FOCUS_FACTS = locale.toLowerCase().startsWith("de") ? "de" : "en"; + return FOCUS_FACTS[key][focusId]; } export const FOCUS_OPTIONS: FocusOption[] = [ @@ -58,28 +84,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 +139,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, timeZone: 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/chart-links.spec.ts b/apps/web/e2e/chart-links.spec.ts index f6d240f..d67b26d 100644 --- a/apps/web/e2e/chart-links.spec.ts +++ b/apps/web/e2e/chart-links.spec.ts @@ -3,89 +3,91 @@ import { captureScreenshot } from "./helpers"; const fixture = "/e2e/fixtures/chart-links.html"; -test("chart links cannot execute script when clicked or expanded", async ({ page }, testInfo) => { - test.setTimeout(120_000); - // ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below. - await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`); - await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 }); - for (const inspect of ["0", "1"]) { - for (const href of [ - 'javascript:void(document.body.dataset.plotExecuted="yes")', - ' \u0000\u001fJaVa\tScRi\nPt:void(document.body.dataset.plotExecuted="yes")', - 'javascript:%76oid(document.body.dataset.plotExecuted="yes")', - "data:text/html,", - "file:///example.txt", - ]) { - await page.goto(`${fixture}?${new URLSearchParams({ href, inspect })}`); - const circle = page.locator("svg circle").first(); - // Every navigation renders Plot again, which takes seconds on a loaded CI runner. - await expect(circle).toBeVisible({ timeout: 30_000 }); - const assertInert = async () => { - const links = page.locator("svg a"); - expect(await links.count()).toBeGreaterThan(0); - expect( - await links.evaluateAll((anchors) => - anchors.every( - (anchor) => - !anchor.hasAttribute("href") && - !anchor.hasAttributeNS("http://www.w3.org/1999/xlink", "href"), - ), - ), - ).toBe(true); - await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed"); - }; - await assertInert(); - await circle.click(); - // Hover inspection still works, without reintroducing unsafe links. - if (inspect === "1") await expect(page.locator('[aria-label="tip"]')).toBeVisible(); - await assertInert(); - await page.getByRole("button", { name: "Expand", exact: true }).click(); - const dialog = page.getByRole("dialog"); - await expect(dialog.locator("svg circle")).toBeVisible({ timeout: 30_000 }); - await dialog.locator("svg circle").click(); - await assertInert(); - await expect(page).toHaveURL(`${fixture}?${new URLSearchParams({ href, inspect })}`); - await page.getByRole("button", { name: "Close chart" }).click(); - } - } - await captureScreenshot(page, testInfo, "chart-with-inert-link"); -}); +test.describe + .serial("chart links", () => { + test("cannot execute script when clicked or expanded", async ({ page }, testInfo) => { + test.setTimeout(120_000); + // ChartCanvas lazy-loads Observable Plot; pay the import cost once before the matrix below. + await page.goto(`${fixture}?${new URLSearchParams({ href: "#details", inspect: "0" })}`); + await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 }); + for (const inspect of ["0", "1"]) { + for (const href of [ + 'javascript:void(document.body.dataset.plotExecuted="yes")', + ' \u0000\u001fJaVa\tScRi\nPt:void(document.body.dataset.plotExecuted="yes")', + 'javascript:%76oid(document.body.dataset.plotExecuted="yes")', + "data:text/html,", + "file:///example.txt", + ]) { + await page.goto(`${fixture}?${new URLSearchParams({ href, inspect })}`); + const circle = page.locator("svg circle").first(); + // Every navigation renders Plot again, which takes seconds on a loaded CI runner. + await expect(circle).toBeVisible({ timeout: 30_000 }); + const assertInert = async () => { + const links = page.locator("svg a"); + expect(await links.count()).toBeGreaterThan(0); + expect( + await links.evaluateAll((anchors) => + anchors.every( + (anchor) => + !anchor.hasAttribute("href") && + !anchor.hasAttributeNS("http://www.w3.org/1999/xlink", "href"), + ), + ), + ).toBe(true); + await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed"); + }; + await assertInert(); + await circle.click(); + // Hover inspection still works, without reintroducing unsafe links. + if (inspect === "1") await expect(page.locator('[aria-label="tip"]')).toBeVisible(); + await assertInert(); + await page.getByRole("button", { name: "Expand", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.locator("svg circle")).toBeVisible({ timeout: 30_000 }); + await dialog.locator("svg circle").click(); + await assertInert(); + await expect(page).toHaveURL(`${fixture}?${new URLSearchParams({ href, inspect })}`); + await page.getByRole("button", { name: "Close chart" }).click(); + } + } + await captureScreenshot(page, testInfo, "chart-with-inert-link"); + }); -test("chart links retain supported URLs and browser encoding semantics", async ({ page }) => { - test.setTimeout(120_000); - await page.route("https://example.test/**", (route) => - route.fulfill({ body: "Chart destination" }), - ); - await page.route("**/e2e/fixtures/java*", (route) => - route.fulfill({ body: "Chart destination" }), - ); - // Each test gets a fresh browser context, so this one pays the cold Plot import too. - await page.goto(`${fixture}?${new URLSearchParams({ href: "#details" })}`); - await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 90_000 }); - for (const href of [ - "https://example.test/chart?a=1&b=2", - "mailto:chart@example.test", - "tel:+15555550123", - "#details", - "javascript:void(0)", - "javascript%3Avoid(0)", - "java%73cript:void(0)", - ]) { - await page.goto(`${fixture}?${new URLSearchParams({ href })}`); - await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 30_000 }); - const link = page.locator("svg a").first(); - await expect(link).toBeAttached(); - expect( - await link.evaluate((anchor) => - anchor.getAttributeNS("http://www.w3.org/1999/xlink", "href"), - ), - ).toBe(href); - const destination = new URL(href, page.url()); - expect(destination.protocol).not.toBe("javascript:"); - if (destination.protocol === "http:" || destination.protocol === "https:") { - await link.locator("circle").click(); - await expect(page).toHaveURL(destination.href); - await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed"); - } - } -}); + test("retain supported URLs and browser encoding semantics", async ({ page }) => { + test.setTimeout(120_000); + await page.route("https://example.test/**", (route) => + route.fulfill({ body: "Chart destination" }), + ); + await page.route("**/e2e/fixtures/java*", (route) => + route.fulfill({ body: "Chart destination" }), + ); + await page.goto(`${fixture}?${new URLSearchParams({ href: "#details" })}`); + await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 30_000 }); + for (const href of [ + "https://example.test/chart?a=1&b=2", + "mailto:chart@example.test", + "tel:+15555550123", + "#details", + "javascript:void(0)", + "javascript%3Avoid(0)", + "java%73cript:void(0)", + ]) { + await page.goto(`${fixture}?${new URLSearchParams({ href })}`); + await expect(page.locator("svg circle").first()).toBeVisible({ timeout: 30_000 }); + const link = page.locator("svg a").first(); + await expect(link).toBeAttached(); + expect( + await link.evaluate((anchor) => + anchor.getAttributeNS("http://www.w3.org/1999/xlink", "href"), + ), + ).toBe(href); + const destination = new URL(href, page.url()); + expect(destination.protocol).not.toBe("javascript:"); + if (destination.protocol === "http:" || destination.protocol === "https:") { + await link.locator("circle").click(); + await expect(page).toHaveURL(destination.href); + await expect(page.locator("body")).not.toHaveAttribute("data-plot-executed"); + } + } + }); + }); 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..821a391 --- /dev/null +++ b/apps/web/e2e/empty-project-state.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; +import { completeOnboarding, routePatchRpc, rpc, signup } from "./helpers"; + +test("empty project space offers 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" }); + + // spaces/create provisions a main conversation server-side; strip it from bootstrap so the + // shell shows the pre-start empty project UI. + await routePatchRpc(page, "bootstrap", (json) => { + const groups = (json.groups as Array<{ spaceId: string }>).filter( + (group) => group.spaceId !== side.id, + ); + const bots = (json.bots as Array<{ spaceId: string }>).filter((bot) => bot.spaceId !== side.id); + const spaces = ( + json.spaces as Array<{ + id: string; + bots: unknown[]; + groups: unknown[]; + hasContent: boolean; + }> + ).map((space) => + space.id === side.id ? { ...space, bots: [], groups: [], hasContent: false } : space, + ); + return { ...json, groups, bots, spaces }; + }); + + await page.evaluate((spaceId) => { + window.localStorage.setItem("rakazo:space-id", spaceId); + }, side.id); + await page.goto("/app", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 60_000, + }); + + const mainStart = page.getByTestId("project-start-main"); + await expect(mainStart).toBeVisible({ timeout: 30_000 }); + await expect(mainStart).toContainText(/Start|Starten/i); + + const projekte = page.locator(".sidebar-projekte"); + const sidebarStart = projekte.getByTestId(`project-start-${side.id}`); + await expect(sidebarStart).toBeVisible({ timeout: 30_000 }); + await expect(sidebarStart).toContainText(/Start|Starten/i); +}); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index b3e4036..8e2d7ea 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -283,9 +283,15 @@ export async function openUserSettings( await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { timeout: 60_000, }); - await expect(page.getByTestId("user-menu-trigger")).toBeVisible({ timeout: 30_000 }); - await page.getByTestId("user-menu-trigger").click(); - await page.getByRole("button", { name: "Settings", exact: true }).click(); + const openNav = page.getByTestId("open-navigation"); + if (await openNav.isVisible().catch(() => false)) { + await openNav.click(); + } + const menuTrigger = page.getByTestId("user-menu-trigger"); + await expect(menuTrigger).toBeVisible({ timeout: 30_000 }); + await menuTrigger.scrollIntoViewIfNeeded(); + await menuTrigger.click(); + await page.getByRole("button", { name: /^Settings$|^Einstellungen$/i }).click(); const settings = page.getByTestId("user-settings"); await expect(settings).toBeVisible(); if (section && section !== "general") { @@ -320,3 +326,54 @@ export async function createNamedBot( await waitForChatComposer(page); return bot.id; } + +export const ALLOWED_CONSOLE_ERRORS = [ + /Failed to load resource.*404/i, + /Failed to load resource.*401/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..f0fa874 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, undefined, { mainConversation: true }); + 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/module-recovery.spec.ts b/apps/web/e2e/module-recovery.spec.ts index 309bdf4..43e6d91 100644 --- a/apps/web/e2e/module-recovery.spec.ts +++ b/apps/web/e2e/module-recovery.spec.ts @@ -20,7 +20,9 @@ test("the app reloads itself once when a module request aborts while booting", a await page.goto("/sign-in", { waitUntil: "commit" }); - await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible({ + timeout: 60_000, + }); expect(counter.aborted).toBe(1); expect(await page.evaluate(() => sessionStorage.getItem("rakazo:boot-reload"))).toBeNull(); }); @@ -30,7 +32,9 @@ test("a lazy route recovers when its module request aborts", async ({ page }) => await page.goto("/sign-in"); - await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Sign in to evOo" })).toBeVisible({ + timeout: 60_000, + }); expect(counter.aborted).toBe(1); expect( await page.evaluate(() => sessionStorage.getItem("rakazo:module-reload:pages/Auth")), 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/lib/desktop.test.ts b/apps/web/src/lib/desktop.test.ts index e150cac..73f6dab 100644 --- a/apps/web/src/lib/desktop.test.ts +++ b/apps/web/src/lib/desktop.test.ts @@ -67,12 +67,15 @@ describe("window chrome", () => { expect(shell).toContain( 'className="app-drag flex items-center justify-between border-b border-sidebar-border', ); - expect(shell).toContain('className="app-no-drag grid size-control-lg'); + expect(shell).toContain( + 'className="app-no-drag rk-touch-target grid size-control-lg min-h-control-lg min-w-control-lg', + ); expect(shell).toContain('className="app-no-drag flex min-w-0 items-center gap-3"'); // Ensure header sidebar toggle controls stay clickable (not part of drag region) expect( - toggles.match(/app-no-drag .* size-control-lg .* place-items-center/g)?.length ?? 0, - ).toBeGreaterThanOrEqual(2); + toggles.match(/app-no-drag rk-touch-target .* size-control-lg .* place-items-center/g) + ?.length ?? 0, + ).toBeGreaterThanOrEqual(1); }); it("moves window chrome into the conversation header when the bots sidebar is collapsed", () => { 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() {