mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-08 01:02:07 +00:00
fix(ui): retire picker focus listeners together (#142258)
This commit is contained in:
parent
7e2241a15d
commit
e5a9b28604
2 changed files with 112 additions and 1 deletions
|
|
@ -1,15 +1,120 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CDPSession } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
captureUiProof,
|
||||
captureUiProofEnabled,
|
||||
chatSessionListResponse,
|
||||
controlUiSessionUrl,
|
||||
createChatFlowE2eSuite,
|
||||
expectDefined,
|
||||
installMockGateway,
|
||||
} from "./chat-flow.test-support.ts";
|
||||
import { createControlUiE2eContextOptions } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createChatFlowE2eSuite();
|
||||
|
||||
async function focusListenerCounts(protocol: CDPSession, selector: string) {
|
||||
const objectGroup = "picker-focus-listeners";
|
||||
try {
|
||||
const { result } = await protocol.send("Runtime.evaluate", {
|
||||
expression: `document.querySelector(${JSON.stringify(selector)})`,
|
||||
objectGroup,
|
||||
});
|
||||
const { listeners } = await protocol.send("DOMDebugger.getEventListeners", {
|
||||
objectId: expectDefined(result.objectId, "mounted picker trigger"),
|
||||
});
|
||||
return {
|
||||
blur: listeners.filter((listener) => listener.type === "blur").length,
|
||||
keydown: listeners.filter((listener) => listener.type === "keydown").length,
|
||||
};
|
||||
} finally {
|
||||
await protocol.send("Runtime.releaseObjectGroup", { objectGroup });
|
||||
}
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it.each(["blur", "keydown"] as const)(
|
||||
"keeps retained trigger listeners bounded when %s completes pointer focus",
|
||||
async (completion) => {
|
||||
await suite.withPage(
|
||||
{
|
||||
locale: "en-US",
|
||||
reducedMotion: "reduce",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
},
|
||||
async ({ context, page }) => {
|
||||
const gateway = await installMockGateway(page, {});
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
const selector =
|
||||
'openclaw-chat-pane[aria-hidden="false"] [data-chat-permission-select="true"]';
|
||||
const trigger = page.locator(selector);
|
||||
const picker = page.locator(
|
||||
'openclaw-chat-pane[aria-hidden="false"] .chat-controls__permission-picker',
|
||||
);
|
||||
const composer = page.locator(
|
||||
'openclaw-chat-pane[aria-hidden="false"] .agent-chat__composer-combobox textarea',
|
||||
);
|
||||
const retainedTrigger = expectDefined(await trigger.elementHandle(), "picker trigger");
|
||||
const protocol = await context.newCDPSession(page);
|
||||
const marker = "data-chat-pointer-restored-focus";
|
||||
try {
|
||||
const cycle = async () => {
|
||||
await trigger.click();
|
||||
await expect.poll(() => trigger.getAttribute(marker)).toBe("");
|
||||
expect(
|
||||
await retainedTrigger.evaluate(
|
||||
(element, triggerSelector) => element === document.querySelector(triggerSelector),
|
||||
selector,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(await trigger.evaluate((element) => document.activeElement === element)).toBe(
|
||||
true,
|
||||
);
|
||||
if (completion === "blur") {
|
||||
await composer.click();
|
||||
} else {
|
||||
await trigger.press("Shift");
|
||||
}
|
||||
await expect.poll(() => trigger.getAttribute(marker)).toBeNull();
|
||||
if (completion === "keydown") {
|
||||
// Close on the same focused trigger; blur would consume the companion listener.
|
||||
await trigger.click();
|
||||
}
|
||||
await expect.poll(() => picker.getAttribute("open")).toBeNull();
|
||||
};
|
||||
// Warm Web Awesome's own bindings, then compare the same completed phase.
|
||||
await cycle();
|
||||
const before = await focusListenerCounts(protocol, selector);
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await cycle();
|
||||
}
|
||||
const after = await focusListenerCounts(protocol, selector);
|
||||
if (captureUiProofEnabled) {
|
||||
await writeFile(
|
||||
path.join(suite.artifactDir, `${completion}-listeners.json`),
|
||||
`${JSON.stringify({ browser: suite.browser.version(), completion, cycles: 20, before, after, retainedTriggerConnected: await retainedTrigger.evaluate((element) => element.isConnected) }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
await captureUiProof(suite, page, "picker-focus", `${completion}-completed.png`);
|
||||
await trigger.click();
|
||||
await expect.poll(() => trigger.getAttribute(marker)).toBe("");
|
||||
await captureUiProof(suite, page, "picker-focus", `${completion}-open.png`);
|
||||
await trigger.press("Shift");
|
||||
await expect.poll(() => trigger.getAttribute(marker)).toBeNull();
|
||||
expect(after).toEqual(before);
|
||||
} finally {
|
||||
await protocol.detach();
|
||||
await retainedTrigger.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("shows the selected permission and disables dropdown motion when requested", async () => {
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,13 @@ function pickerTrigger(picker: HTMLElement): HTMLElement | null {
|
|||
: picker.querySelector<HTMLElement>("[slot=trigger]");
|
||||
}
|
||||
|
||||
function clearPointerFocus(this: HTMLElement): void {
|
||||
// Blur and keyboard takeover complete the same pointer-focus lifetime.
|
||||
this.removeEventListener("blur", clearPointerFocus);
|
||||
this.removeEventListener("keydown", clearPointerFocus);
|
||||
this.removeAttribute(POINTER_RESTORED_FOCUS_ATTRIBUTE);
|
||||
}
|
||||
|
||||
function dismissChatComposerPickersOutside(event: PointerEvent): void {
|
||||
const path = event.composedPath();
|
||||
for (const picker of openChatComposerPickers()) {
|
||||
|
|
@ -186,7 +193,6 @@ export function restorePointerOpenedChatComposerTrigger(event: Event): void {
|
|||
return;
|
||||
}
|
||||
trigger.setAttribute(POINTER_RESTORED_FOCUS_ATTRIBUTE, "");
|
||||
const clearPointerFocus = () => trigger.removeAttribute(POINTER_RESTORED_FOCUS_ATTRIBUTE);
|
||||
trigger.addEventListener("blur", clearPointerFocus, { once: true });
|
||||
trigger.addEventListener("keydown", clearPointerFocus, { once: true });
|
||||
trigger.focus({ preventScroll: true });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue