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. */ function unreadableTexts(locator: Locator, inside: boolean) { return locator.evaluate( (root, { inside, 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"), ) : [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, min: MIN_CONTRAST }, ); } async function expectReadable(locator: Locator, what: string, { inside = false } = {}) { await expect(locator).toBeVisible(); await expect .poll(() => unreadableTexts(locator, inside), { message: `${what} is readable` }) .toEqual([]); } async function inEachTheme(page: Page, check: (theme: "light" | "dark") => Promise) { 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 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, }); await expectReadable( sidebar.getByRole("button", { name: "Heute", exact: true }), `${theme}: Heute`, { inside: true }, ); await expectReadable( sidebar.locator("[data-main-conversation]"), `${theme}: the main conversation row`, { inside: true }, ); await expectReadable( sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" }), `${theme}: the selected bot row`, { inside: true }, ); await expectReadable(page.getByTestId("user-menu-trigger"), `${theme}: the user menu`, { inside: true, }); }); 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 }); }); });