import { expect, test, type Page } from "@playwright/test"; import { mockAgentApi } from "./support/mock-agent-api"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { expectThemeColor } 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 keeps one thin focus boundary without an outer blue ring", async ({ page }) => { await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); const form = page.locator("form.agent-panel-control"); const textarea = page.getByPlaceholder(/输入调度问题/); const restingShadow = await form.evaluate((element) => getComputedStyle(element).boxShadow); expect(restingShadow).not.toBe("none"); const restingBox = await form.boundingBox(); await textarea.click(); await expectThemeColor(form, "border-color", "--action-blue-hover"); await expect(form).toHaveCSS("border-width", "1px"); await expect(form).toHaveCSS("box-shadow", restingShadow); await expect(textarea).toHaveCSS("outline-style", "none"); expect(await form.boundingBox()).toEqual(restingBox); await page.keyboard.press("Tab"); await textarea.focus(); await expectThemeColor(form, "border-color", "--action-blue-hover"); 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("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 owns a single inset focus stroke at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 844 }); await page.getByRole("button", { name: "打开 Agent 面板" }).click(); const textarea = page.getByPlaceholder(/输入调度问题/); await textarea.click(); const material = page.locator(".agent-panel-composer"); await expect(material).toHaveCSS("outline-width", "1px"); await expect(material).toHaveCSS("outline-offset", "-1px"); await expectThemeColor(material, "outline-color", "--action-blue-hover"); await expect(textarea).toHaveCSS("outline-style", "none"); 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. const paths = ["/node_modules/.vite/deps/react.js", "/node_modules/.vite/deps/react-dom_client.js", "/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", "/src/shared/ai-elements/prompt-input.tsx"]; const [react, dom, inputs, textareas, groups, selects, dates, primitives, checkboxes, radios, prompts] = 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", onValueChange: () => {} }), h(primitives.Select, { defaultValue: "first" }, h(prompts.PromptInputSelectTrigger, { "aria-label": "无边框选择" }, 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(); }