diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 5370279ce54e..aa2ab52dc686 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -51,16 +51,35 @@ const boardFixtureHtml = ` + OpenClaw Board Fixture + diff --git a/ui/src/e2e/board-fixture.e2e.test.ts b/ui/src/e2e/board-fixture.e2e.test.ts new file mode 100644 index 000000000000..a1fedfd7896b --- /dev/null +++ b/ui/src/e2e/board-fixture.e2e.test.ts @@ -0,0 +1,216 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import { once } from "node:events"; +import { type AddressInfo, createServer } from "node:net"; +import path from "node:path"; +import type { Readable } from "node:stream"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { chromium, type Browser, type Page } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + resolvePlaywrightChromiumExecutablePath, +} from "../test-helpers/control-ui-e2e.ts"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeBoardFixture = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +type FixtureProcess = ChildProcessByStdio; + +type FixtureServer = { + child: FixtureProcess; + url: string; + output: () => string; +}; + +async function reservePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const port = (server.address() as AddressInfo).port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function startFixtureServer(): Promise { + const port = await reservePort(); + const url = `http://127.0.0.1:${port}/__fixtures/board/`; + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + "scripts/control-ui-mock-dev.ts", + "--host", + "127.0.0.1", + "--port", + String(port), + ], + { + cwd: repoRoot, + env: { ...process.env, CI: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + output += chunk; + }); + child.stderr.on("data", (chunk: string) => { + output += chunk; + }); + + for (let attempt = 0; attempt < 100; attempt += 1) { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`board fixture server exited before startup\n${output}`); + } + try { + const response = await fetch(url); + if (response.ok) { + return { child, url, output: () => output }; + } + } catch {} + await delay(100); + } + + child.kill("SIGTERM"); + throw new Error(`timed out waiting for board fixture server\n${output}`); +} + +async function stopFixtureServer(server: FixtureServer | undefined): Promise { + if (!server || server.child.exitCode !== null || server.child.signalCode !== null) { + return; + } + const exited = once(server.child, "exit"); + server.child.kill("SIGTERM"); + await Promise.race([exited, delay(5_000)]); + if (server.child.exitCode === null && server.child.signalCode === null) { + server.child.kill("SIGKILL"); + await exited; + } +} + +function colorChannelToLinear(channel: number): number { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +function colorLuminance(color: string): number { + const match = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/); + if (!match) { + throw new Error(`unsupported computed color: ${color}`); + } + if (match[4] !== undefined && Number(match[4]) !== 1) { + throw new Error(`transparent computed color requires compositing: ${color}`); + } + const channels = match.slice(1, 4).map(Number); + return ( + 0.2126 * colorChannelToLinear(channels[0]!) + + 0.7152 * colorChannelToLinear(channels[1]!) + + 0.0722 * colorChannelToLinear(channels[2]!) + ); +} + +function colorContrast(foreground: string, background: string): number { + const lighter = Math.max(colorLuminance(foreground), colorLuminance(background)); + const darker = Math.min(colorLuminance(foreground), colorLuminance(background)); + return (lighter + 0.05) / (darker + 0.05); +} + +async function openWidgetMenu(page: Page): Promise { + const widget = page.locator('[data-test-id="board-widget"]').first(); + await widget.focus(); + await widget.locator(".board-widget__menu-trigger").click(); + await page.locator(".board-widget__menu[open]").waitFor(); +} + +async function readMenuColors(page: Page): Promise<{ background: string; foreground: string }> { + return page.evaluate(() => { + const dropdown = document.querySelector(".board-widget__menu"); + const item = dropdown?.querySelector("wa-dropdown-item:not(.board-widget__menu-danger)"); + const menu = dropdown?.shadowRoot?.querySelector('[part~="menu"]'); + if (!(item instanceof HTMLElement) || !(menu instanceof HTMLElement)) { + throw new Error("board fixture menu did not expose its surface and first item"); + } + return { + background: getComputedStyle(menu).backgroundColor, + foreground: getComputedStyle(item).color, + }; + }); +} + +let browser: Browser; +let fixtureServer: FixtureServer; + +describeBoardFixture("standalone board fixture", () => { + beforeAll(async () => { + fixtureServer = await startFixtureServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true }); + }); + + afterAll(async () => { + await browser?.close(); + await stopFixtureServer(fixtureServer); + }); + + for (const mode of ["dark", "light"] as const) { + it(`themes dropdown items and widget frames in ${mode} mode`, async () => { + const context = await browser.newContext({ colorScheme: mode }); + try { + const page = await context.newPage(); + await page.goto(fixtureServer.url, { waitUntil: "networkidle" }); + await expect + .poll(() => + page.locator("html").evaluate((root) => ({ + classes: [...root.classList], + theme: (root as HTMLElement).dataset.theme, + themeMode: (root as HTMLElement).dataset.themeMode, + })), + ) + .toEqual({ classes: [`wa-${mode}`], theme: mode, themeMode: mode }); + + await openWidgetMenu(page); + const colors = await readMenuColors(page); + expect(colorContrast(colors.foreground, colors.background)).toBeGreaterThanOrEqual(4.5); + + await expect + .poll(() => page.frames().some((frame) => frame.url().startsWith("data:text/html"))) + .toBe(true); + const widgetFrame = page.frames().find((frame) => frame.url().startsWith("data:text/html")); + expect(widgetFrame).toBeDefined(); + await expect + .poll(() => + widgetFrame!.evaluate(() => getComputedStyle(document.documentElement).colorScheme), + ) + .toBe(mode); + } finally { + await context.close(); + } + }); + } + + it("follows live system color-scheme changes", async () => { + const context = await browser.newContext({ colorScheme: "dark" }); + try { + const page = await context.newPage(); + await page.goto(fixtureServer.url, { waitUntil: "networkidle" }); + await page.emulateMedia({ colorScheme: "light" }); + await expect.poll(() => page.locator("html").getAttribute("data-theme-mode")).toBe("light"); + await expect.poll(() => page.locator("html").getAttribute("class")).toBe("wa-light"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/test-helpers/board-fixture.ts b/ui/src/test-helpers/board-fixture.ts index 764b8b1a9fa3..331961b20ca3 100644 --- a/ui/src/test-helpers/board-fixture.ts +++ b/ui/src/test-helpers/board-fixture.ts @@ -160,6 +160,10 @@ function frameUrl(name: string): string { font-weight:650;letter-spacing:-.06em;margin-top:16px}.detail{color:#77cdb5;font-size:11px;margin-top:9px} .ticks{display:flex;align-items:end;gap:5px;height:52px;margin-top:20px}.ticks i{display:block;flex:1; min-width:4px;border-radius:2px 2px 0 0;background:#39434d}.ticks i:nth-child(3n){background:#4ec9a8} + @media(prefers-color-scheme:light){:root{color-scheme:light}body{color:#403c35;background: + radial-gradient(circle at 85% 5%,rgba(20,184,166,.12),transparent 38%),#f4f1ec} + .eyebrow{color:#6e6960}.detail{color:#0f766e}.ticks i{background:#d8d1c7} + .ticks i:nth-child(3n){background:#14b8a6}}
${copy.eyebrow}
${copy.value}
${copy.detail}
${[28, 52, 37, 68, 44, 81, 59, 72] .map((height) => ``)