evOo-local/apps/web/e2e/chat-contrast.spec.ts
cynarAI 0c3e3f3d29
feat(web): complete German, English sources and catalogs that cannot go stale (#111)
* fix(web): use English Lingui sources for German UI text

Several web surfaces hard-coded German (the project card, roster and
activity status labels, the sidebar "Heute" label) or used German text as
the Lingui source message (sidebar sections, project timeline, bot picker,
voice entry, group stage, copy and read-aloud buttons). English users saw
German, and because production builds keep only message ids, none of these
strings could be translated per locale.

Convert them to English source messages, keeping the meaning:
- ProjectCreatedCard: Lingui Trans/Plural instead of literals.
- agent-activity: labels go through i18n._ with literal ids so the module
  stays importable from plain unit tests; the extractor still collects them.
- Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage:
  English sources, reusing existing messages where one already fit
  ("just now", "{n}m ago", "Search", "No bots match", "Set up voice",
  "Group conversation", "Transcript").
- The conversation picker matches its create entry against the translated
  label instead of a fixed German string.

The German wording returns through the de catalog in the next commit.
Unit and E2E tests now assert the English labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(web): keep Lingui catalogs complete and in sync

The committed catalogs were last extracted weeks ago: about 330 source
messages were missing from every locale, and production builds keep only
message ids, so those strings rendered as hash ids (sidebar sections,
privacy settings, profile, timeline, ...). German also had 226 empty
entries, and every extraction rewrote the files because references carried
line numbers.

- lingui.config.ts: po references without line numbers, and a stable sort
  that breaks Lingui's ordering ties (explicit ids, equal text) by id, so
  the output no longer depends on file-system order.
- Re-extract all catalogs with --clean (drops 25-44 obsolete entries per
  locale; no existing translation changed).
- Translate every German entry (du form, consistent with the existing
  catalog). Other locales keep empty entries and fall back to English.
- i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails
  when the committed files differ or a German entry is empty, so stale
  catalogs fail the unit test job (offline, about 5 s).

Adds @lingui/format-po (already installed through @lingui/cli) as a direct
dev dependency to configure the formatter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test(web): find the sidebar Today button by its English label

The shell contrast test added on main looks up the sidebar button by the
old German source text, which this branch replaces with "Today".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* chore(web): extract catalogs and translate the in-app browser strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 10:04:21 +02:00

264 lines
11 KiB
TypeScript

import { expect, type Locator, type Page, test } from "@playwright/test";
import {
captureScreenshot,
completeOnboarding,
createNamedBot,
openNewBot,
rpc,
signup,
waitForChatComposer,
} from "./helpers";
// WCAG AA for body text.
const MIN_CONTRAST = 4.5;
test.use({ viewport: { width: 1280, height: 1000 } });
/**
* Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every
* visible text and typed value within it counts, except in disabled controls, which WCAG exempts,
* and in elements matching `skip`.
*/
function unreadableTexts(locator: Locator, inside: boolean, skip: string | null) {
return locator.evaluate(
(root, { inside, skip, min }) => {
const context = document
.createElement("canvas")
.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("No 2D canvas context");
// The canvas normalizes every CSS color syntax (oklab, color-mix, ...) to sRGB.
const rgba = (color: string) => {
context.clearRect(0, 0, 1, 1);
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255, a / 255] as const;
};
// One pixel of `color`, painted inside `from` and composited through every ancestor's
// background color and opacity onto the white canvas.
const paint = (color: readonly [number, number, number, number], from: Element) => {
let [r, g, b, a] = color;
for (let node: Element | null = from; node; node = node.parentElement) {
const style = getComputedStyle(node);
const [br, bg, bb, ba] = rgba(style.backgroundColor);
const alpha = a + ba * (1 - a);
if (alpha > 0) {
r = (r * a + br * ba * (1 - a)) / alpha;
g = (g * a + bg * ba * (1 - a)) / alpha;
b = (b * a + bb * ba * (1 - a)) / alpha;
}
a = alpha * Number(style.opacity);
}
return [r * a + 1 - a, g * a + 1 - a, b * a + 1 - a] as const;
};
const luminance = (rgb: readonly [number, number, number]) => {
const [r = 0, g = 0, b = 0] = rgb.map((channel) =>
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (element: Element) => {
const text = luminance(paint(rgba(getComputedStyle(element).color), element));
const surface = luminance(paint([0, 0, 0, 0], element));
return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05);
};
// What the element itself shows: a typed value, or its own text nodes.
const ownText = (element: Element) => {
if (element instanceof HTMLTextAreaElement) return element.value;
if (element instanceof HTMLInputElement) {
return /^(text|search|url|email|tel|number|password)$/.test(element.type)
? element.value
: "";
}
return Array.from(element.childNodes, (node) =>
node.nodeType === Node.TEXT_NODE ? node.textContent : "",
)
.join("")
.trim();
};
const shown = (element: Element) => {
const { width, height } = element.getBoundingClientRect();
return width > 1 && height > 1 && element.checkVisibility();
};
const elements = inside
? [root, ...root.querySelectorAll("*")].filter(
(element) =>
ownText(element) &&
shown(element) &&
!element.closest(":disabled") &&
!(skip && element.closest(skip)),
)
: [root];
return elements.flatMap((element) => {
const ratio = contrast(element);
const text = ownText(element) || element.textContent?.trim() || "";
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
});
},
{ inside, skip, min: MIN_CONTRAST },
);
}
async function expectReadable(
locator: Locator,
what: string,
{ inside = false, skip = null }: { inside?: boolean; skip?: string | null } = {},
) {
await expect(locator).toBeVisible();
await expect
.poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
.toEqual([]);
}
async function inEachTheme(page: Page, check: (theme: "light" | "dark") => Promise<void>) {
for (const theme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme: theme });
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
await check(theme);
}
}
function runStatus(page: Page, groupId: string) {
return rpc<{ run?: { status: string } | null }>(page, "threads/get", { groupId }).then(
(snapshot) => snapshot.run?.status ?? "idle",
);
}
async function send(page: Page, groupId: string, text: string) {
await expect
.poll(() => runStatus(page, groupId), { timeout: 60_000 })
.not.toMatch(/^(queued|running)$/);
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill(text);
await composer.press("Enter");
}
test("conversation text stays readable in light and dark themes", async ({ page }, testInfo) => {
const stamp = Date.now();
await page.emulateMedia({ colorScheme: "light" });
await signup(page, `chat-contrast-${stamp}@rakazo.test`, "password12", "Contrast QA");
await completeOnboarding(page, undefined, { mainConversation: true });
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
await rpc(page, "bots/create", {
name: "Researcher",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
await page.reload();
await waitForChatComposer(page);
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
const transcript = page.getByTestId("transcript");
// The scripted bot echoes the request, so its reply renders this markdown.
await send(
page,
groupId,
"Summarize the **owl facts** in `notes/owls.md`\n\n## Owl notes\n\n> Owls turn their heads\n\n```\nowls = 3\n```",
);
const firstMessage = transcript
.getByTestId("message-user-bubble")
.filter({ hasText: "Summarize the **owl facts**" });
const markdown = transcript
.locator(".rk-chat-markdown")
.filter({ hasText: "done. i handled: Summarize the owl facts" });
const reply = markdown.locator("p").filter({ hasText: "done. i handled" });
await expect(reply).toBeVisible({ timeout: 60_000 });
await send(page, groupId, "delegate to the bot named Researcher: collect three owl facts");
const task = transcript.getByTestId("delegated-task-card");
await expect(task).toHaveAttribute("data-status", "done", { timeout: 60_000 });
await expect(
transcript.getByText("closing the task: collect three owl facts.").first(),
).toBeVisible({ timeout: 60_000 });
await send(page, groupId, "ask me which city to use");
await expect.poll(() => runStatus(page, groupId), { timeout: 60_000 }).toBe("waiting_input");
const question = transcript
.locator(".rk-chat-markdown p")
.filter({ hasText: /^Which city should I use\?$/ });
// threads/get can observe waiting_input before the realtime feed paints the ask card.
if ((await question.count()) === 0) {
await page.reload({ waitUntil: "domcontentloaded" });
await waitForChatComposer(page);
}
await expect(question).toBeVisible({ timeout: 15_000 });
const lastMessage = transcript
.getByTestId("message-user-bubble")
.filter({ hasText: "ask me which city to use" });
await task.locator("summary").click();
const brief = task.locator(".rk-chat-markdown p");
await expect(brief).toHaveText("collect three owl facts");
await inEachTheme(page, async (theme) => {
await firstMessage.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-reply`);
await expectReadable(firstMessage, `${theme}: the user's own message`);
await expectReadable(reply, `${theme}: a bot reply`);
await expectReadable(reply.locator("strong"), `${theme}: bold text in a bot reply`);
await expectReadable(reply.locator("code"), `${theme}: inline code in a bot reply`);
await expectReadable(markdown.locator("h2"), `${theme}: a heading in a bot reply`);
await expectReadable(markdown.locator("blockquote p"), `${theme}: a quote in a bot reply`);
await expectReadable(markdown.locator("pre code"), `${theme}: a code block in a bot reply`);
await brief.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-task`);
await expectReadable(brief, `${theme}: the delegated task brief`);
await question.scrollIntoViewIfNeeded();
await captureScreenshot(page, testInfo, `chat-contrast-${theme}-ask`);
await expectReadable(question, `${theme}: the ask card question`);
await expectReadable(lastMessage, `${theme}: the latest user message`);
});
});
test("shell surfaces stay readable in light and dark themes", async ({ page }, testInfo) => {
await page.emulateMedia({ colorScheme: "light" });
await signup(page, `shell-contrast-${Date.now()}@rakazo.test`, "password12", "Contrast QA");
await completeOnboarding(page);
await createNamedBot(page, "Researcher");
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("Draft a note about owls");
const sidebar = page.getByTestId("bots-sidebar");
const sidePanel = page.getByTestId("side-panel");
await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
await expectReadable(composer, `${theme}: the composer draft`);
await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
inside: true,
});
// The header carries the logo, which WCAG exempts; its create button is checked on its own.
await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
});
await openNewBot(page);
await page.getByTestId("create-bot-form").locator("label:has-text('Name') input").fill("Owl");
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-new-bot`);
await expectReadable(sidePanel, `${theme}: the new bot form`, { inside: true });
});
await page.getByRole("button", { name: "Cancel new bot" }).click();
await page.getByTestId("bot-settings-trigger").click();
const advanced = page.getByTestId("bot-settings-advanced");
await advanced.locator("summary").click();
await expect(advanced.getByText("Open work")).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
});
await sidebar.getByRole("button", { name: "Today", exact: true }).click();
const heute = page.getByTestId("heute-brief");
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
});
});