190 lines
11 KiB
TypeScript
190 lines
11 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { mockAgentApi } from "./support/mock-agent-api";
|
|
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
|
import { expectThemeColor, readThemeColor } from "./support/theme-colors";
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await mockWorkbenchContext(page);
|
|
await mockAgentApi(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
});
|
|
|
|
test("Agent composer uses a neutral shadow instead of a blue focus border", async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
|
|
const form = page.locator("form.agent-panel-control");
|
|
const inputSurface = form.locator(".agent-panel-input-body");
|
|
const textarea = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
|
|
const restingShadow = await form.evaluate((element) => getComputedStyle(element).boxShadow);
|
|
const restingBorder = await form.evaluate((element) => getComputedStyle(element).borderColor);
|
|
const restingSurface = await inputSurface.evaluate((element) => getComputedStyle(element).backgroundColor);
|
|
expect(restingShadow).not.toBe("none");
|
|
const restingBox = await form.boundingBox();
|
|
await textarea.click();
|
|
await expect(form).toHaveCSS("border-color", restingBorder);
|
|
await expect(form).toHaveCSS("border-width", "1px");
|
|
await expect(form).not.toHaveCSS("box-shadow", restingShadow);
|
|
await expect(form).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -2)");
|
|
await expect(textarea).toHaveCSS("outline-style", "none");
|
|
await expect(inputSurface).toHaveCSS("background-color", restingSurface);
|
|
const liftedBox = await form.boundingBox();
|
|
expect(liftedBox?.x).toBeCloseTo(restingBox!.x, 1);
|
|
expect(liftedBox?.y).toBeCloseTo(restingBox!.y - 2, 1);
|
|
expect(liftedBox?.width).toBeCloseTo(restingBox!.width, 1);
|
|
expect(liftedBox?.height).toBeCloseTo(restingBox!.height, 1);
|
|
await page.keyboard.press("Tab");
|
|
await textarea.focus();
|
|
await expect(form).toHaveCSS("border-color", restingBorder);
|
|
const focusedBox = await form.boundingBox();
|
|
await page.screenshot({
|
|
path: "test-results/form-focus/agent-focused.png",
|
|
clip: { x: focusedBox!.x - 4, y: focusedBox!.y - 4, width: focusedBox!.width + 8, height: focusedBox!.height + 8 }
|
|
});
|
|
});
|
|
|
|
test("shared fields and grouped inputs do not stack native outlines and focus rings", async ({ page }) => {
|
|
await renderFields(page);
|
|
for (const label of ["共享输入", "共享多行输入", "组合输入", "选择字段", "日期字段"]) {
|
|
const control = page.getByLabel(label, { exact: true });
|
|
await page.keyboard.press("Tab");
|
|
await control.focus();
|
|
await expect(control).toHaveCSS("outline-style", "none");
|
|
const owner = label === "组合输入" ? page.getByRole("group", { name: "组合输入区" }) : control;
|
|
await expectThemeColor(owner, "border-color", "--action-blue-hover");
|
|
await expect(owner).toHaveCSS("border-width", "1px");
|
|
const shadow = await owner.evaluate((element) => getComputedStyle(element).boxShadow);
|
|
expect(shadow).not.toMatch(/0px 0px 0px [12]px/);
|
|
}
|
|
const input = page.getByLabel("共享输入", { exact: true });
|
|
await input.evaluate((element) => element.setAttribute("aria-invalid", "true"));
|
|
await input.focus();
|
|
await expectThemeColor(input, "border-color", "--action-danger");
|
|
const groupedInput = page.getByLabel("组合输入", { exact: true });
|
|
await groupedInput.evaluate((element) => element.setAttribute("aria-invalid", "true"));
|
|
await groupedInput.focus();
|
|
await expectThemeColor(page.getByRole("group", { name: "组合输入区" }), "border-color", "--action-danger");
|
|
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/shared-fields.png" });
|
|
});
|
|
|
|
test("filled fields retain readable text inside muted labels and keep placeholder and disabled states", async ({ page }) => {
|
|
await renderFields(page);
|
|
const input = page.getByLabel("共享输入", { exact: true });
|
|
const textarea = page.getByLabel("共享多行输入", { exact: true });
|
|
for (const control of [input, textarea]) {
|
|
await control.evaluate((element) => {
|
|
const label = document.createElement("label");
|
|
label.className = "text-muted-foreground";
|
|
element.replaceWith(label);
|
|
label.append(element);
|
|
});
|
|
await expectThemeColor(control, "color", "--ink");
|
|
expect(await control.evaluate((element) => getComputedStyle(element, "::placeholder").color))
|
|
.toBe(await readThemeColor(control, "--ink-muted"));
|
|
await control.fill("已填写的供水核查信息");
|
|
await expectThemeColor(control, "color", "--ink");
|
|
await expect(control).toHaveCSS("opacity", "1");
|
|
await control.blur();
|
|
await expectThemeColor(control, "color", "--ink");
|
|
await control.evaluate((element) => { (element as HTMLInputElement).readOnly = true; });
|
|
await expectThemeColor(control, "color", "--ink");
|
|
await expect(control).toHaveCSS("opacity", "1");
|
|
await control.evaluate((element) => { (element as HTMLInputElement).disabled = true; });
|
|
await expect(control).toHaveCSS("opacity", "0.5");
|
|
}
|
|
});
|
|
|
|
test("borderless selects and choice controls keep one theme keyboard focus indicator", async ({ page }) => {
|
|
await renderFields(page);
|
|
const borderless = page.getByRole("combobox", { name: "无边框选择" });
|
|
await page.keyboard.press("Tab");
|
|
await borderless.focus();
|
|
await expect(borderless).toHaveCSS("border-width", "0px");
|
|
await expect(borderless).toHaveCSS("outline-style", "solid");
|
|
await expect(borderless).toHaveCSS("outline-width", "1px");
|
|
await expect(borderless).toHaveCSS("outline-offset", "-1px");
|
|
await expectThemeColor(borderless, "outline-color", "--action-blue-hover");
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.getByRole("option", { name: "第二个选项" })).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(borderless).toBeFocused();
|
|
|
|
const firstRadio = page.getByRole("radio", { name: "单选一" });
|
|
await firstRadio.focus();
|
|
await expect(firstRadio).toHaveCSS("appearance", "none");
|
|
await page.keyboard.press("ArrowRight");
|
|
await expect(page.getByRole("radio", { name: "单选二" })).toBeChecked();
|
|
const checkbox = page.getByRole("checkbox", { name: "多选" });
|
|
await checkbox.focus();
|
|
await expect(checkbox).toHaveCSS("appearance", "none");
|
|
await page.keyboard.press("Space");
|
|
await expect(checkbox).toBeChecked();
|
|
await expect(checkbox).toHaveCSS("outline-style", "none");
|
|
const shadow = await checkbox.evaluate((element) => getComputedStyle(element).boxShadow);
|
|
expect(shadow).toContain("0px 0px 0px 4px");
|
|
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/choice-fields.png" });
|
|
});
|
|
|
|
for (const width of [320, 375]) {
|
|
test(`mobile composer keeps a neutral focus treatment at ${width}px`, async ({ page }) => {
|
|
await page.setViewportSize({ width, height: 844 });
|
|
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
|
|
const textarea = page.getByRole("textbox", { name: "询问供水问题,或描述需要处理的任务" });
|
|
const material = page.locator("form.agent-panel-input-form");
|
|
const inputSurface = page.locator(".agent-panel-input-body");
|
|
const restingBorder = await material.evaluate((element) => getComputedStyle(element).borderColor);
|
|
const restingShadow = await material.evaluate((element) => getComputedStyle(element).boxShadow);
|
|
const restingSurface = await inputSurface.evaluate((element) => getComputedStyle(element).backgroundColor);
|
|
const restingBox = await material.boundingBox();
|
|
await textarea.click();
|
|
await expect(material).toHaveCSS("border-width", "1px");
|
|
await expect(material).toHaveCSS("border-color", restingBorder);
|
|
await expect(material).not.toHaveCSS("box-shadow", restingShadow);
|
|
await expect(textarea).toHaveCSS("outline-style", "none");
|
|
await expect(inputSurface).toHaveCSS("background-color", restingSurface);
|
|
expect(await material.boundingBox()).toEqual(restingBox);
|
|
await page.screenshot({ path: `test-results/form-focus/mobile-${width}.png` });
|
|
});
|
|
}
|
|
|
|
async function renderFields(page: Page) {
|
|
await page.evaluate(async () => {
|
|
// Load the production components through Vite, so the test exercises their real CSS.
|
|
// Use the app's exact React modules, including Vite's per-port cache and hash.
|
|
const entry = await fetch("/src/main.tsx").then((response) => response.text());
|
|
const reactPath = entry.match(/["']([^"']*\/react\.js(?:\?[^"']*)?)["']/)?.[1];
|
|
const domPath = entry.match(/["']([^"']*\/react-dom_client\.js(?:\?[^"']*)?)["']/)?.[1];
|
|
if (!reactPath || !domPath) throw new Error("App React dependency URLs not found");
|
|
const paths = [reactPath, domPath,
|
|
"/src/shared/ui/input.tsx", "/src/shared/ui/textarea.tsx", "/src/shared/ui/input-group.tsx",
|
|
"/src/shared/ui/field-select.tsx", "/src/shared/ui/date-time-field.tsx", "/src/shared/ui/select.tsx",
|
|
"/src/shared/ui/checkbox.tsx", "/src/shared/ui/radio.tsx"];
|
|
const [react, dom, inputs, textareas, groups, selects, dates, primitives, checkboxes, radios] = await Promise.all(paths.map((path) => import(path)));
|
|
const h = react.default.createElement;
|
|
const container = document.createElement("div");
|
|
container.id = "focus-fields";
|
|
container.style.cssText = "position:fixed;z-index:40;top:80px;left:80px;width:360px;padding:24px;background:var(--surface-reading);display:grid;gap:16px";
|
|
document.body.append(container);
|
|
dom.default.createRoot(container).render(h(react.default.Fragment, null,
|
|
h(inputs.Input, { "aria-label": "共享输入", placeholder: "共享输入" }),
|
|
h(textareas.Textarea, { "aria-label": "共享多行输入", placeholder: "共享多行输入" }),
|
|
h(groups.InputGroup, { "aria-label": "组合输入区" }, h(groups.InputGroupInput, { "aria-label": "组合输入", placeholder: "组合输入" })),
|
|
h(selects.FieldSelect, { label: "选择字段", value: "first", onValueChange: () => {}, options: [{ value: "first", label: "第一个选项" }, { value: "second", label: "第二个选项" }] }),
|
|
h(dates.DateTimeField, { label: "日期字段", value: "2026-10-09T09:00", onChange: () => {} }),
|
|
h(primitives.Select, { defaultValue: "first" },
|
|
h(primitives.SelectTrigger, { "aria-label": "无边框选择", className: "form-field-inset border-none bg-transparent shadow-none" }, h(primitives.SelectValue)),
|
|
h(primitives.SelectContent, null,
|
|
h(primitives.SelectItem, { value: "first" }, "第一个选项"),
|
|
h(primitives.SelectItem, { value: "second" }, "第二个选项")
|
|
)
|
|
),
|
|
h("div", { style: { display: "flex", gap: "16px" } },
|
|
h("label", null, h(radios.Radio, { name: "focus-radio", defaultChecked: true }), "单选一"),
|
|
h("label", null, h(radios.Radio, { name: "focus-radio" }), "单选二"),
|
|
h("label", null, h(checkboxes.Checkbox), "多选")
|
|
)
|
|
));
|
|
});
|
|
await expect(page.getByLabel("共享输入", { exact: true })).toBeVisible();
|
|
}
|