// Control UI tests cover form controls behavior.
import { chromium, type Browser, type Page } from "playwright";
import { describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures.js";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const describeBrowserLayout = canRunPlaywrightChromium(chromiumExecutablePath)
? describe
: describe.skip;
type MobileFixture = {
browser: Browser;
page: Page;
};
function readUiCss(): string {
const files = [
"ui/src/styles/base.css",
"ui/src/styles/components.css",
"ui/src/styles/config.css",
"ui/src/styles/settings.css",
"ui/src/styles/layout.css",
"ui/src/styles/usage.css",
"ui/src/styles/chat/layout.css",
];
return files.map((file) => readStyleSheet(file)).join("\n");
}
function controlsHtml() {
return `
`;
}
async function openMobileFixture(): Promise {
const browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
let page: Page | undefined;
try {
page = await browser.newPage({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
await page.setContent(
`${controlsHtml()}`,
);
return { browser, page };
} catch (error) {
await page?.close().catch(() => {});
await browser.close().catch(() => {});
throw error;
}
}
async function closeMobileFixture(fixture: MobileFixture): Promise {
await fixture.page.close().catch(() => {});
await fixture.browser.close().catch(() => {});
}
describeBrowserLayout("touch-primary form controls", () => {
it("keeps text-entry controls large enough to avoid mobile focus zoom", async () => {
const fixture = await openMobileFixture();
const { page } = fixture;
try {
const metrics = await page.evaluate(() => {
const selectors = [
".field input",
".field textarea",
".field select",
".config-search__input",
".settings-sidebar__search-input",
".settings-theme-import__input",
".config-raw-field textarea",
"input.settings-input",
".settings-row__control > textarea.settings-input",
".settings-select",
".usage-date-input",
".usage-select",
".usage-query-input",
'.usage-filters-inline input[type="text"]',
".usage-filters-inline select",
".agent-chat__composer-combobox > textarea",
];
return {
touchPrimary: matchMedia("(hover: none) and (pointer: coarse)").matches,
sizes: selectors.map((selector) => {
const node = document.querySelector(selector);
if (!(node instanceof HTMLElement)) {
throw new Error(`Missing control ${selector}`);
}
return {
selector,
fontSize: Number.parseFloat(getComputedStyle(node).fontSize),
};
}),
};
});
expect(metrics.touchPrimary).toBe(true);
for (const size of metrics.sizes) {
expect(size.fontSize, size.selector).toBeGreaterThanOrEqual(16);
}
} finally {
await closeMobileFixture(fixture);
}
});
it("keeps native select affordances visible in light mode", async () => {
const fixture = await openMobileFixture();
const { page } = fixture;
try {
const selects = await page.locator(".field select").evaluateAll((nodes) =>
nodes.map((node) => {
const style = getComputedStyle(node as HTMLElement);
return {
image: style.backgroundImage,
paddingRight: Number.parseFloat(style.paddingRight),
repeat: style.backgroundRepeat,
};
}),
);
expect(selects).toHaveLength(1);
for (const select of selects) {
expect(select.image).not.toBe("none");
expect(select.paddingRight).toBeGreaterThanOrEqual(32);
expect(select.repeat).toContain("no-repeat");
}
} finally {
await closeMobileFixture(fixture);
}
});
it("aligns text controls without stretching checkbox and radio inputs", async () => {
const fixture = await openMobileFixture();
const { page } = fixture;
try {
const dimensions = await page.evaluate(() => {
const height = (selector: string) => {
const node = document.querySelector(selector);
if (!(node instanceof HTMLElement)) {
throw new Error(`Missing control ${selector}`);
}
return node.getBoundingClientRect().height;
};
return {
checkbox: height('.field input[type="checkbox"]'),
radio: height('.field input[type="radio"]'),
select: height(".field select"),
text: height('.field input[type="text"]'),
};
});
expect(dimensions.text).toBe(38);
expect(dimensions.select).toBe(38);
expect(dimensions.checkbox).toBeLessThan(38);
expect(dimensions.radio).toBeLessThan(38);
} finally {
await closeMobileFixture(fixture);
}
});
});
describeBrowserLayout("mount fallback cursor", () => {
it("uses the default cursor for its controls and the pointer for its real link", async () => {
const browser = await chromium.launch({
executablePath: chromiumExecutablePath,
headless: true,
});
try {
const page = await browser.newPage();
await page.setContent(readStyleSheet("ui/index.html"));
const cursors = await page.evaluate(() => {
const cursor = (selector: string) => {
const node = document.querySelector(selector);
if (!(node instanceof HTMLElement)) {
throw new Error(`Missing cursor fixture ${selector}`);
}
return getComputedStyle(node).cursor;
};
return {
retry: cursor("#openclaw-mount-retry"),
wait: cursor("#openclaw-mount-wait"),
docs: cursor('.mount-fallback__panel a[href^="https://"]'),
};
});
expect(cursors).toEqual({
retry: "default",
wait: "default",
docs: "pointer",
});
} finally {
await browser.close().catch(() => {});
}
});
});
describeBrowserLayout("app chrome interaction styles", () => {
it("keeps sidebars compact while preserving normal content scroll and text entry", async () => {
const browser = await chromium.launch({
executablePath: chromiumExecutablePath,
headless: true,
});
try {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
await page.setContent(`
App chrome tile
`);
const metrics = await page.evaluate(() => {
const style = (selector: string) => {
const node = document.querySelector(selector);
if (!(node instanceof HTMLElement)) {
throw new Error(`Missing interaction fixture ${selector}`);
}
return getComputedStyle(node);
};
const scrollbarWidth = (selector: string) => {
const node = document.querySelector(selector);
if (!(node instanceof HTMLElement)) {
throw new Error(`Missing scrollbar fixture ${selector}`);
}
return getComputedStyle(node, "::-webkit-scrollbar").width;
};
return {
chatSelection: style(".chat-thread").userSelect,
chromeSelection: style(".settings-card").userSelect,
contentScrollbar: scrollbarWidth(".content"),
inputSelection: style(".settings-sidebar__search-input").userSelect,
regularSidebarScrollbar: scrollbarWidth(".sidebar-recent-sessions"),
regularSidebarSelection: style(".sidebar-recent-sessions").userSelect,
settingsSidebarScrollbar: scrollbarWidth(".settings-sidebar__nav"),
settingsSidebarSelection: style(".settings-sidebar__nav").userSelect,
};
});
expect(metrics).toEqual({
chatSelection: "text",
chromeSelection: "none",
contentScrollbar: "12px",
inputSelection: "text",
regularSidebarScrollbar: "6px",
regularSidebarSelection: "none",
settingsSidebarScrollbar: "6px",
settingsSidebarSelection: "none",
});
} finally {
await browser.close().catch(() => {});
}
});
});