mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(interaction): verify focus changes and honor cancellation
This commit is contained in:
@@ -599,6 +599,70 @@ describe("ToolDispatcher", () => {
|
||||
expect(onSessionsChanged).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"focus",
|
||||
"blur",
|
||||
] as const)("routes %s with hover cleanup and cooperative cancellation", async (action) => {
|
||||
const tab = { id: 7, windowId: 4242, active: true };
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
get: vi.fn(async () => tab),
|
||||
query: vi.fn(async () => [tab]),
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
},
|
||||
});
|
||||
const sessions = new SessionManager({
|
||||
agentWindow: {
|
||||
create: async () => 4242,
|
||||
remove: async () => {},
|
||||
ensureActiveTab: async () => 7,
|
||||
},
|
||||
});
|
||||
const ctx = await sessions.start("aa11");
|
||||
ctx.refStore.set("e1", 12, { tabId: 7 });
|
||||
const { transport, sent, deliver } = fakeTransport();
|
||||
const onBrowserControlResumed = vi.fn();
|
||||
let resolveNode: ((value: object) => void) | undefined;
|
||||
const cdp = {
|
||||
send: vi.fn(async (_tabId: number, method: string) => {
|
||||
if (method === "DOM.resolveNode")
|
||||
return new Promise((resolve) => {
|
||||
resolveNode = resolve;
|
||||
});
|
||||
if (method === "Runtime.callFunctionOn") return { result: { value: true } };
|
||||
return {};
|
||||
}),
|
||||
} as unknown as TestDispatcherCdp;
|
||||
const dispatcher = new ToolDispatcher({ transport, sessions, cdp, onBrowserControlResumed });
|
||||
const hover = dispatcher as unknown as {
|
||||
rememberHover: (sessionId: string, result: object) => void;
|
||||
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
|
||||
};
|
||||
hover.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
|
||||
await hover.setHoverBypass("aa11", 7, true);
|
||||
dispatcher.start();
|
||||
deliver(makeRequest(`tool.${action}`, { session_id: "aa11", ref: "e1" }));
|
||||
await vi.waitFor(() => expect(resolveNode).toBeDefined());
|
||||
expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11");
|
||||
deliver({ id: "cancel-focus", method: "cancel", params: { rpc_id: "r-1" } });
|
||||
resolveNode!({ object: { objectId: "focus-target" } });
|
||||
await vi.waitFor(() => expect(sent).toHaveLength(2));
|
||||
expect(sent).toContainEqual({ id: "cancel-focus", result: { cancelled: true } });
|
||||
expect(sent).toContainEqual({
|
||||
id: "r-1",
|
||||
error: expect.objectContaining({ code: "cancelled" }),
|
||||
});
|
||||
expect(cdp.send).not.toHaveBeenCalledWith(7, "DOM.focus", expect.anything());
|
||||
expect(cdp.send).not.toHaveBeenCalledWith(7, "Runtime.callFunctionOn", expect.anything());
|
||||
expect(cdp.send).toHaveBeenCalledWith(7, "Runtime.releaseObject", { objectId: "focus-target" });
|
||||
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({ enabled: false }),
|
||||
);
|
||||
expect(dispatcher.inflightAbortControllers.size).toBe(0);
|
||||
dispatcher.stop();
|
||||
});
|
||||
|
||||
it("invokes onBrowserControlResumed for browser-control tools but not passive reads", async () => {
|
||||
vi.stubGlobal("chrome", {
|
||||
tabs: {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// @vitest-environment node
|
||||
// Opt in with BSK_FOCUS_CHROME=/path/to/chrome; each test owns its browser/profile.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import { handleBlur, handleFocus } from "../interaction";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
type Send = <T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: object,
|
||||
sessionId?: string,
|
||||
) => Promise<T>;
|
||||
|
||||
async function withFocusBrowser(
|
||||
run: (h: {
|
||||
evaluate: (expression: string) => Promise<unknown>;
|
||||
ref: () => Promise<void>;
|
||||
focus: (signal?: AbortSignal) => ReturnType<typeof handleFocus>;
|
||||
blur: (signal?: AbortSignal) => ReturnType<typeof handleBlur>;
|
||||
calls: string[];
|
||||
afterCommand: { current: (method: string) => void };
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
// Reuse the existing isolated Chrome launcher; no new browser dependency.
|
||||
const { withChrome } = await import(
|
||||
new URL(
|
||||
"../../../../../evals/browser/cases/regression/snapshot-coordinates/chrome.mjs",
|
||||
import.meta.url,
|
||||
).href
|
||||
);
|
||||
await withChrome(
|
||||
{ executable: process.env.BSK_FOCUS_CHROME, deviceScale: 1, zoom: 1 },
|
||||
async (send: Send) => {
|
||||
const { targetId } = await send<{ targetId: string }>("Target.createTarget", {
|
||||
url: "about:blank",
|
||||
});
|
||||
const { sessionId } = await send<{ sessionId: string }>("Target.attachToTarget", {
|
||||
targetId,
|
||||
flatten: true,
|
||||
});
|
||||
await send("Page.bringToFront", {}, sessionId);
|
||||
const evaluate = async (expression: string) => {
|
||||
const reply = await send<{ result: { value: unknown }; exceptionDetails?: unknown }>(
|
||||
"Runtime.evaluate",
|
||||
{ expression, returnByValue: true },
|
||||
sessionId,
|
||||
);
|
||||
expect(reply.exceptionDetails).toBeUndefined();
|
||||
return reply.result.value;
|
||||
};
|
||||
await evaluate(
|
||||
`document.body.innerHTML = '<input id="target"><input id="other">'; window.probe = document.querySelector('#target');`,
|
||||
);
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: async () => 100,
|
||||
remove: async () => {},
|
||||
ensureActiveTab: async () => 4,
|
||||
},
|
||||
});
|
||||
const ctx = await manager.start("aa11");
|
||||
const calls: string[] = [];
|
||||
const afterCommand = { current: (_method: string) => {} };
|
||||
const cdp: CdpRunner = {
|
||||
send: async (_tabId, method, params) => {
|
||||
calls.push(method);
|
||||
const result = await send(method, params, sessionId);
|
||||
afterCommand.current(method);
|
||||
return result as never;
|
||||
},
|
||||
};
|
||||
const deps = {
|
||||
cdp,
|
||||
tabsApi: {
|
||||
get: async (id: number) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab,
|
||||
query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab],
|
||||
},
|
||||
};
|
||||
await run({
|
||||
evaluate,
|
||||
calls,
|
||||
afterCommand,
|
||||
ref: async () => {
|
||||
const { result } = await send<{ result: { objectId: string } }>(
|
||||
"Runtime.evaluate",
|
||||
{ expression: "window.probe" },
|
||||
sessionId,
|
||||
);
|
||||
const { node } = await send<{ node: { backendNodeId: number } }>(
|
||||
"DOM.describeNode",
|
||||
{ objectId: result.objectId },
|
||||
sessionId,
|
||||
);
|
||||
await send("Runtime.releaseObject", { objectId: result.objectId }, sessionId);
|
||||
ctx.refStore.set("e1", node.backendNodeId, { tabId: 4 });
|
||||
},
|
||||
focus: (signal) =>
|
||||
handleFocus(manager, { session_id: "aa11", ref: "e1" }, { ...deps, signal }),
|
||||
blur: (signal) =>
|
||||
handleBlur(manager, { session_id: "aa11", ref: "e1" }, { ...deps, signal }),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.BSK_FOCUS_CHROME)("real browser focus and blur", () => {
|
||||
it.each(["light", "open", "closed"])("verifies focus and blur in %s DOM", async (mode) => {
|
||||
await withFocusBrowser(async (h) => {
|
||||
if (mode !== "light")
|
||||
await h.evaluate(`(() => {
|
||||
const host = document.createElement('div'); document.body.append(host);
|
||||
const nested = document.createElement('div'); host.attachShadow({mode:'${mode}'}).append(nested);
|
||||
const input = document.createElement('input'); nested.attachShadow({mode:'${mode}'}).append(input);
|
||||
window.probe = input;
|
||||
})()`);
|
||||
await h.ref();
|
||||
expect(await h.focus()).toMatchObject({ focused: true });
|
||||
expect(await h.evaluate("window.probe.matches(':focus')")).toBe(true);
|
||||
expect(await h.blur()).toMatchObject({ was_focused: true, focused: false });
|
||||
expect(await h.evaluate("window.probe.matches(':focus')")).toBe(false);
|
||||
expect(h.calls.filter((method) => method === "Runtime.releaseObject")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("detects focus changes from event-handler microtasks", async () => {
|
||||
await withFocusBrowser(async (h) => {
|
||||
await h.ref();
|
||||
await h.evaluate(
|
||||
"window.probe.addEventListener('focus', () => queueMicrotask(() => document.querySelector('#other').focus()), {once:true})",
|
||||
);
|
||||
expect(await h.focus()).toMatchObject({ code: "cdp_failed" });
|
||||
expect(await h.focus()).toMatchObject({ focused: true });
|
||||
await h.evaluate(
|
||||
"window.probe.addEventListener('blur', () => queueMicrotask(() => window.probe.focus()), {once:true})",
|
||||
);
|
||||
expect(await h.blur()).toMatchObject({ code: "cdp_failed" });
|
||||
expect(await h.evaluate("window.probe.matches(':focus')")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels before blur while still releasing the resolved object", async () => {
|
||||
await withFocusBrowser(async (h) => {
|
||||
await h.ref();
|
||||
await h.focus();
|
||||
const abort = new AbortController();
|
||||
h.afterCommand.current = (method) => {
|
||||
if (method === "DOM.resolveNode") abort.abort();
|
||||
};
|
||||
expect(await h.blur(abort.signal)).toMatchObject({ code: "cancelled" });
|
||||
expect(await h.evaluate("window.probe.matches(':focus')")).toBe(true);
|
||||
expect(h.calls.at(-1)).toBe("Runtime.releaseObject");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a page exception with its cause", async () => {
|
||||
await withFocusBrowser(async (h) => {
|
||||
await h.ref();
|
||||
await h.evaluate("window.probe.blur = () => { throw new Error('page blur failed'); }");
|
||||
expect(await h.blur()).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
message: expect.stringContaining("page blur failed"),
|
||||
});
|
||||
expect(h.calls.at(-1)).toBe("Runtime.releaseObject");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import { handleBlur, handleFocus } from "../interaction";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
interface ScriptParams {
|
||||
functionDeclaration: string;
|
||||
}
|
||||
interface Call {
|
||||
target: CdpTarget;
|
||||
method: string;
|
||||
}
|
||||
|
||||
async function setup(element?: HTMLElement, childSession = false) {
|
||||
if (!element) {
|
||||
document.body.innerHTML = '<input id="target"><input id="other">';
|
||||
element = document.querySelector<HTMLInputElement>("#target")!;
|
||||
}
|
||||
const targetElement = element;
|
||||
const manager = new SessionManager({
|
||||
agentWindow: {
|
||||
create: async () => 100,
|
||||
remove: async () => {},
|
||||
ensureActiveTab: async () => 4,
|
||||
},
|
||||
});
|
||||
const ctx = await manager.start("aa11");
|
||||
ctx.refStore.set("e1", 12, {
|
||||
tabId: 4,
|
||||
...(childSession ? { frameId: "child", cdpSessionId: "child-session" } : {}),
|
||||
});
|
||||
const calls: Call[] = [];
|
||||
const afterCommand = vi.fn<(call: Call) => void>();
|
||||
const script = vi.fn(async (params: ScriptParams) => {
|
||||
try {
|
||||
const fn = new Function(`return (${params.functionDeclaration})`)();
|
||||
return { result: { value: await fn.call(targetElement) } };
|
||||
} catch (error) {
|
||||
return { exceptionDetails: { text: "Uncaught", exception: { description: String(error) } } };
|
||||
}
|
||||
});
|
||||
const focus = vi.fn(() => targetElement.focus());
|
||||
const release = vi.fn(async () => ({}));
|
||||
const send = async (target: CdpTarget, method: string, params?: object) => {
|
||||
const call = { target, method };
|
||||
calls.push(call);
|
||||
try {
|
||||
switch (method) {
|
||||
case "DOM.getDocument":
|
||||
return { root: { nodeId: 1 } };
|
||||
case "DOM.querySelector":
|
||||
return { nodeId: 2 };
|
||||
case "DOM.describeNode":
|
||||
return { node: { backendNodeId: 12 } };
|
||||
case "DOM.scrollIntoViewIfNeeded":
|
||||
return {};
|
||||
case "DOM.resolveNode":
|
||||
return { object: { objectId: "focus-target" } };
|
||||
case "DOM.focus":
|
||||
focus();
|
||||
return {};
|
||||
case "Runtime.callFunctionOn":
|
||||
return await script(params as ScriptParams);
|
||||
case "Runtime.releaseObject":
|
||||
return await release();
|
||||
default:
|
||||
throw new Error(`unexpected ${method}`);
|
||||
}
|
||||
} finally {
|
||||
afterCommand(call);
|
||||
}
|
||||
};
|
||||
const cdp: CdpRunner = {
|
||||
send: (tabId, method, params) => send({ tabId }, method, params) as never,
|
||||
sendToTarget: send as CdpRunner["sendToTarget"],
|
||||
getFrameGraph: vi.fn(async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
...(childSession
|
||||
? [
|
||||
{
|
||||
frameId: "child",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 99,
|
||||
target: { tabId: 4, sessionId: "child-session" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
})),
|
||||
};
|
||||
const tabsApi = {
|
||||
get: vi.fn(async (id: number) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab),
|
||||
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
|
||||
};
|
||||
return {
|
||||
element: targetElement,
|
||||
calls,
|
||||
afterCommand,
|
||||
script,
|
||||
focus,
|
||||
release,
|
||||
cdp,
|
||||
tabsApi,
|
||||
run: (
|
||||
action: "focus" | "blur",
|
||||
signal?: AbortSignal,
|
||||
params: { ref?: string; selector?: string; tab_id?: number } = { ref: "e1" },
|
||||
) =>
|
||||
(action === "focus" ? handleFocus : handleBlur)(
|
||||
manager,
|
||||
{ session_id: "aa11", ...params },
|
||||
{ cdp, tabsApi, signal },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("focus and blur state verification", () => {
|
||||
it.each(["open", "closed"] as const)("handles nested %s shadow roots", async (mode) => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const outer = host.attachShadow({ mode });
|
||||
const innerHost = document.createElement("div");
|
||||
outer.append(innerHost);
|
||||
const inner = innerHost.attachShadow({ mode });
|
||||
const element = document.createElement("input");
|
||||
inner.append(element);
|
||||
const h = await setup(element);
|
||||
expect(await h.run("focus")).toMatchObject({ focused: true });
|
||||
expect(inner.activeElement).toBe(element);
|
||||
expect(await h.run("blur")).toMatchObject({ was_focused: true, focused: false });
|
||||
expect(inner.activeElement).toBeNull();
|
||||
expect(h.release).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps DOM focus usable when the document is in the background", async () => {
|
||||
const h = await setup();
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||
expect(await h.run("focus")).toMatchObject({ focused: true });
|
||||
expect(await h.run("blur")).toMatchObject({ was_focused: true, focused: false });
|
||||
});
|
||||
|
||||
it("blurring an unfocused target preserves the other element's focus", async () => {
|
||||
const h = await setup();
|
||||
const other = document.querySelector<HTMLInputElement>("#other")!;
|
||||
other.focus();
|
||||
expect(await h.run("blur")).toMatchObject({ was_focused: false, focused: false });
|
||||
expect(document.activeElement).toBe(other);
|
||||
});
|
||||
|
||||
it("detects focus redirected by a focus handler's microtask", async () => {
|
||||
const h = await setup();
|
||||
h.element.addEventListener("focus", () =>
|
||||
queueMicrotask(() => document.querySelector<HTMLInputElement>("#other")!.focus()),
|
||||
);
|
||||
expect(await h.run("focus")).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
message: "target element did not become focused",
|
||||
});
|
||||
expect(h.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not report blur success when a microtask restores focus", async () => {
|
||||
const h = await setup();
|
||||
h.element.focus();
|
||||
h.element.addEventListener("blur", () => queueMicrotask(() => h.element.focus()));
|
||||
expect(await h.run("blur")).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
message: "target element remained focused after blur()",
|
||||
});
|
||||
expect(document.activeElement).toBe(h.element);
|
||||
expect(h.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["focus", "blur"] as const)("rejects a detached %s target", async (action) => {
|
||||
const h = await setup();
|
||||
h.element.remove();
|
||||
expect(await h.run(action)).toMatchObject({ code: "cdp_failed" });
|
||||
expect(h.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves a script exception's cause", async () => {
|
||||
const h = await setup();
|
||||
vi.spyOn(h.element, "blur").mockImplementation(() => {
|
||||
throw new Error("page blur failed");
|
||||
});
|
||||
expect(await h.run("blur")).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
message: expect.stringContaining("page blur failed"),
|
||||
});
|
||||
expect(h.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["focus", "blur"] as const)("rejects malformed %s script results", async (action) => {
|
||||
const h = await setup();
|
||||
h.script.mockResolvedValueOnce({ result: { value: null } });
|
||||
expect(await h.run(action)).toMatchObject({
|
||||
code: "cdp_failed",
|
||||
message: expect.stringContaining("unexpected result"),
|
||||
});
|
||||
expect(h.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"focus",
|
||||
"blur",
|
||||
] as const)("keeps %s, verification and cleanup in the OOPIF session", async (action) => {
|
||||
const h = await setup(undefined, true);
|
||||
if (action === "blur") h.element.focus();
|
||||
expect(await h.run(action)).toMatchObject({ focused: action === "focus" });
|
||||
const targetCalls = h.calls.filter((call) => call.method !== "DOM.scrollIntoViewIfNeeded");
|
||||
expect(targetCalls.every((call) => call.target.sessionId === "child-session")).toBe(true);
|
||||
expect(targetCalls.at(-1)?.method).toBe("Runtime.releaseObject");
|
||||
});
|
||||
|
||||
it.each(["focus", "blur"] as const)("rejects %s on a user tab before CDP", async (action) => {
|
||||
const h = await setup();
|
||||
h.tabsApi.get.mockResolvedValue({ id: 9, windowId: 200, active: true } as chrome.tabs.Tab);
|
||||
expect(await h.run(action, undefined, { ref: "e1", tab_id: 9 })).toMatchObject({
|
||||
code: "permission_denied",
|
||||
});
|
||||
expect(h.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not replace a successful result when navigation disposed the remote object", async () => {
|
||||
const h = await setup();
|
||||
h.release.mockRejectedValue(new Error("Cannot find context"));
|
||||
expect(await h.run("focus")).toMatchObject({ focused: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus and blur cancellation", () => {
|
||||
const boundaries = [
|
||||
"DOM.getDocument",
|
||||
"DOM.querySelector",
|
||||
"DOM.describeNode",
|
||||
"DOM.resolveNode",
|
||||
"Runtime.callFunctionOn",
|
||||
];
|
||||
for (const action of ["focus", "blur"] as const) {
|
||||
it.each(
|
||||
action === "focus" ? [...boundaries, "DOM.scrollIntoViewIfNeeded", "DOM.focus"] : boundaries,
|
||||
)(`${action} stops after cancellation during %s`, async (method) => {
|
||||
const h = await setup();
|
||||
const abort = new AbortController();
|
||||
let cancelledAfter = 0;
|
||||
h.afterCommand.mockImplementation((call) => {
|
||||
if (call.method === method && !abort.signal.aborted) {
|
||||
cancelledAfter = h.calls.length;
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
expect(await h.run(action, abort.signal, { selector: "#target" })).toMatchObject({
|
||||
code: "cancelled",
|
||||
});
|
||||
expect(abort.signal.aborted).toBe(true);
|
||||
expect(
|
||||
h.calls.slice(cancelledAfter).every((call) => call.method === "Runtime.releaseObject"),
|
||||
).toBe(true);
|
||||
if (h.calls.some((call) => call.method === "DOM.resolveNode"))
|
||||
expect(h.release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it(`${action} stops if cancellation arrives while resolving the tab`, async () => {
|
||||
const h = await setup();
|
||||
const abort = new AbortController();
|
||||
h.tabsApi.query.mockImplementation(async () => {
|
||||
abort.abort();
|
||||
return [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab];
|
||||
});
|
||||
expect(await h.run(action, abort.signal)).toMatchObject({ code: "cancelled" });
|
||||
expect(h.calls).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("does not scroll frame owners after cancellation in an OOPIF", async () => {
|
||||
const h = await setup(undefined, true);
|
||||
const abort = new AbortController();
|
||||
h.afterCommand.mockImplementation((call) => {
|
||||
if (call.method === "DOM.scrollIntoViewIfNeeded") abort.abort();
|
||||
});
|
||||
expect(await h.run("focus", abort.signal)).toMatchObject({ code: "cancelled" });
|
||||
expect(h.calls.filter((call) => call.method === "DOM.scrollIntoViewIfNeeded")).toHaveLength(1);
|
||||
expect(h.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -553,7 +553,7 @@ describe("handleFocus and handleBlur", () => {
|
||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||
"DOM.focus": () => ({}),
|
||||
"DOM.resolveNode": () => ({ object: { objectId: "focus-target" } }),
|
||||
"Runtime.callFunctionOn": () => ({ result: { value: { focused: true } } }),
|
||||
"Runtime.callFunctionOn": () => ({ result: { value: true } }),
|
||||
});
|
||||
|
||||
const res = await handleFocus(
|
||||
@@ -566,9 +566,10 @@ describe("handleFocus and handleBlur", () => {
|
||||
expect(res).toMatchObject({ tab_id: 4, used_ref: "e3", focused: true });
|
||||
expect(fake.sent.map((call) => call.method)).toEqual([
|
||||
"DOM.scrollIntoViewIfNeeded",
|
||||
"DOM.focus",
|
||||
"DOM.resolveNode",
|
||||
"DOM.focus",
|
||||
"Runtime.callFunctionOn",
|
||||
"Runtime.releaseObject",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -599,9 +600,9 @@ describe("handleFocus and handleBlur", () => {
|
||||
fake.cdp.sendToTarget = vi.fn(async (target, method) => {
|
||||
targetCalls.push({ sessionId: target.sessionId, method });
|
||||
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||
if (method === "DOM.focus") return {};
|
||||
if (method === "DOM.focus" || method === "Runtime.releaseObject") return {};
|
||||
if (method === "DOM.resolveNode") return { object: { objectId: "focus-target" } };
|
||||
if (method === "Runtime.callFunctionOn") return { result: { value: { focused: true } } };
|
||||
if (method === "Runtime.callFunctionOn") return { result: { value: true } };
|
||||
throw new Error(`unexpected child CDP call ${method}`);
|
||||
}) as CdpRunner["sendToTarget"];
|
||||
|
||||
@@ -615,9 +616,10 @@ describe("handleFocus and handleBlur", () => {
|
||||
expect(res.focused).toBe(true);
|
||||
expect(targetCalls).toEqual([
|
||||
{ sessionId: "child-session", method: "DOM.scrollIntoViewIfNeeded" },
|
||||
{ sessionId: "child-session", method: "DOM.focus" },
|
||||
{ sessionId: "child-session", method: "DOM.resolveNode" },
|
||||
{ sessionId: "child-session", method: "DOM.focus" },
|
||||
{ sessionId: "child-session", method: "Runtime.callFunctionOn" },
|
||||
{ sessionId: "child-session", method: "Runtime.releaseObject" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -649,9 +651,10 @@ describe("handleFocus and handleBlur", () => {
|
||||
ctx.refStore.set("e3", 1234, { tabId: 4 });
|
||||
const fake = makeFakeCdp({
|
||||
"DOM.resolveNode": () => ({ object: { objectId: "focus-target" } }),
|
||||
"Runtime.callFunctionOn": () => ({
|
||||
result: { value: { ok: true, was_focused: true, focused: false } },
|
||||
}),
|
||||
"Runtime.callFunctionOn": vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ result: { value: { ok: true, was_focused: true } } })
|
||||
.mockResolvedValueOnce({ result: { value: false } }),
|
||||
});
|
||||
|
||||
const res = await handleBlur(
|
||||
@@ -670,6 +673,8 @@ describe("handleFocus and handleBlur", () => {
|
||||
expect(fake.sent.map((call) => call.method)).toEqual([
|
||||
"DOM.resolveNode",
|
||||
"Runtime.callFunctionOn",
|
||||
"Runtime.callFunctionOn",
|
||||
"Runtime.releaseObject",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -230,154 +230,204 @@ export async function resolveBackendNode(
|
||||
}
|
||||
}
|
||||
|
||||
type FocusCheck = { focused: boolean };
|
||||
type BlurMutation =
|
||||
| { ok: true; was_focused: boolean; focused: boolean }
|
||||
| { ok: false; was_focused: boolean; focused: boolean };
|
||||
|
||||
const DEEP_FOCUS_CHECK = `function() {
|
||||
const deepActiveElement = (root) => {
|
||||
let active = root && root.activeElement;
|
||||
while (active && active.shadowRoot && active.shadowRoot.activeElement) {
|
||||
active = active.shadowRoot.activeElement;
|
||||
}
|
||||
return active;
|
||||
};
|
||||
return { focused: deepActiveElement(this.ownerDocument) === this };
|
||||
// Check the target's own root, then its hosts: closed shadow roots are not
|
||||
// reachable via host.shadowRoot. DOM focus remains meaningful in background tabs.
|
||||
const FOCUS_CHECK = `function() {
|
||||
if (!this.isConnected) return false;
|
||||
let element = this;
|
||||
while (element) {
|
||||
const root = element.getRootNode();
|
||||
if (root.activeElement !== element) return false;
|
||||
element = root.host;
|
||||
}
|
||||
return true;
|
||||
}`;
|
||||
|
||||
const BLUR_TARGET = `function() {
|
||||
const deepActiveElement = (root) => {
|
||||
let active = root && root.activeElement;
|
||||
while (active && active.shadowRoot && active.shadowRoot.activeElement) {
|
||||
active = active.shadowRoot.activeElement;
|
||||
}
|
||||
return active;
|
||||
};
|
||||
const wasFocused = deepActiveElement(this.ownerDocument) === this;
|
||||
if (typeof this.blur !== 'function') {
|
||||
return { ok: false, was_focused: wasFocused, focused: wasFocused };
|
||||
}
|
||||
if (!this.isConnected) throw new Error('blur target is detached');
|
||||
const wasFocused = (${FOCUS_CHECK}).call(this);
|
||||
if (typeof this.blur !== 'function') return { ok: false, was_focused: wasFocused };
|
||||
this.blur();
|
||||
return {
|
||||
ok: true,
|
||||
was_focused: wasFocused,
|
||||
focused: deepActiveElement(this.ownerDocument) === this,
|
||||
};
|
||||
return { ok: true, was_focused: wasFocused };
|
||||
}`;
|
||||
|
||||
interface FocusScriptReply<T> {
|
||||
result?: { value?: T };
|
||||
exceptionDetails?: { text?: string; exception?: { description?: string } };
|
||||
}
|
||||
|
||||
function checkFocusAbort(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) throw new DOMException("interaction aborted", "AbortError");
|
||||
}
|
||||
|
||||
// Only focus/blur use this view. Guard every CDP boundary, including commands
|
||||
// inside selector resolution and frame scrolling, without changing other tools.
|
||||
function focusCdp(cdp: CdpRunner, signal: AbortSignal | undefined): CdpRunner {
|
||||
const send = <T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T> => {
|
||||
checkFocusAbort(signal);
|
||||
return cdpRunnerForTarget(cdp, target).send<T>(target.tabId, method, params);
|
||||
};
|
||||
return {
|
||||
send: (tabId, method, params) => send({ tabId }, method, params),
|
||||
sendToTarget: send,
|
||||
trackSessionTab: cdp.trackSessionTab?.bind(cdp),
|
||||
getFrameGraph: cdp.getFrameGraph
|
||||
? (tabId) => {
|
||||
checkFocusAbort(signal);
|
||||
return cdp.getFrameGraph!(tabId);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tool.focus / tool.blur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function handleFocus(
|
||||
export function handleFocus(
|
||||
manager: SessionManager,
|
||||
params: FocusParams,
|
||||
deps: InteractionDeps = getDefaultDeps(),
|
||||
): Promise<FocusResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "focus");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const ctx = ctxOrErr;
|
||||
const aborted = throwIfAborted(deps.signal);
|
||||
if (aborted) return aborted;
|
||||
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
||||
if (isRpcError(target)) return target;
|
||||
const denied = enforceAgentWindow(ctx, target, "focus");
|
||||
if (denied) return denied;
|
||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "focus");
|
||||
if (isRpcError(node)) return node;
|
||||
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
||||
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
const scrollErr = await scrollElementAndFramesIntoView(
|
||||
deps.cdp,
|
||||
target.tabId,
|
||||
node.cdpTarget,
|
||||
node.backendNodeId,
|
||||
node.frameId,
|
||||
);
|
||||
if (scrollErr) return scrollErr;
|
||||
const abortedBeforeFocus = throwIfAborted(deps.signal);
|
||||
if (abortedBeforeFocus) return abortedBeforeFocus;
|
||||
|
||||
try {
|
||||
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||
const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, node.backendNodeId);
|
||||
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
|
||||
const evaluated = await nodeCdp.send<{ result?: { value?: FocusCheck } }>(
|
||||
target.tabId,
|
||||
"Runtime.callFunctionOn",
|
||||
{
|
||||
objectId: objectIdOrErr,
|
||||
functionDeclaration: DEEP_FOCUS_CHECK,
|
||||
returnByValue: true,
|
||||
},
|
||||
);
|
||||
if (evaluated.result?.value?.focused !== true) {
|
||||
return { code: "invalid_params", message: "target element did not become focused" };
|
||||
}
|
||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||
tab_id: target.tabId,
|
||||
used_ref: node.usedRef,
|
||||
used_selector: node.usedSelector,
|
||||
focused: true,
|
||||
});
|
||||
} catch (err) {
|
||||
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
return changeFocus(manager, params, deps, "focus");
|
||||
}
|
||||
|
||||
export async function handleBlur(
|
||||
export function handleBlur(
|
||||
manager: SessionManager,
|
||||
params: BlurParams,
|
||||
deps: InteractionDeps = getDefaultDeps(),
|
||||
): Promise<BlurResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "blur");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const ctx = ctxOrErr;
|
||||
const aborted = throwIfAborted(deps.signal);
|
||||
if (aborted) return aborted;
|
||||
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
||||
if (isRpcError(target)) return target;
|
||||
const denied = enforceAgentWindow(ctx, target, "blur");
|
||||
if (denied) return denied;
|
||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "blur");
|
||||
if (isRpcError(node)) return node;
|
||||
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||
const abortedBeforeBlur = throwIfAborted(deps.signal);
|
||||
if (abortedBeforeBlur) return abortedBeforeBlur;
|
||||
return changeFocus(manager, params, deps, "blur");
|
||||
}
|
||||
|
||||
function changeFocus(
|
||||
manager: SessionManager,
|
||||
params: FocusParams,
|
||||
deps: InteractionDeps,
|
||||
action: "focus",
|
||||
): Promise<FocusResult | RpcError>;
|
||||
function changeFocus(
|
||||
manager: SessionManager,
|
||||
params: BlurParams,
|
||||
deps: InteractionDeps,
|
||||
action: "blur",
|
||||
): Promise<BlurResult | RpcError>;
|
||||
async function changeFocus(
|
||||
manager: SessionManager,
|
||||
params: FocusParams | BlurParams,
|
||||
deps: InteractionDeps,
|
||||
action: "focus" | "blur",
|
||||
): Promise<FocusResult | BlurResult | RpcError> {
|
||||
const ctx = lookupSession(manager, params, action);
|
||||
if (isRpcError(ctx)) return ctx;
|
||||
const cdp = focusCdp(deps.cdp, deps.signal);
|
||||
let objectId: string | undefined;
|
||||
let cleanupCdp: CdpRunner | undefined;
|
||||
let tabId: number | undefined;
|
||||
try {
|
||||
const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, node.backendNodeId);
|
||||
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
|
||||
const evaluated = await nodeCdp.send<{ result?: { value?: BlurMutation } }>(
|
||||
target.tabId,
|
||||
"Runtime.callFunctionOn",
|
||||
{
|
||||
objectId: objectIdOrErr,
|
||||
functionDeclaration: BLUR_TARGET,
|
||||
returnByValue: true,
|
||||
},
|
||||
);
|
||||
const mutation = evaluated.result?.value;
|
||||
if (!mutation?.ok) {
|
||||
return { code: "invalid_params", message: "target element does not support blur()" };
|
||||
checkFocusAbort(deps.signal);
|
||||
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
|
||||
checkFocusAbort(deps.signal);
|
||||
if (isRpcError(target)) return target;
|
||||
const denied = enforceAgentWindow(ctx, target, action);
|
||||
if (denied) return denied;
|
||||
tabId = target.tabId;
|
||||
const dialogCursor = markDialogCursor(deps.cdp, tabId);
|
||||
const node = await resolveBackendNode(cdp, ctx, target, params, action);
|
||||
checkFocusAbort(deps.signal);
|
||||
if (isRpcError(node)) return node;
|
||||
const nodeCdp = cdpRunnerForTarget(cdp, node.cdpTarget);
|
||||
cleanupCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
||||
deps.cdp.trackSessionTab?.(ctx.sessionId, tabId);
|
||||
|
||||
if (action === "focus") {
|
||||
const scrollErr = await scrollElementAndFramesIntoView(
|
||||
cdp,
|
||||
tabId,
|
||||
node.cdpTarget,
|
||||
node.backendNodeId,
|
||||
node.frameId,
|
||||
);
|
||||
checkFocusAbort(deps.signal);
|
||||
if (scrollErr) return scrollErr;
|
||||
}
|
||||
if (mutation.focused) {
|
||||
return { code: "cdp_failed", message: "target element remained focused after blur()" };
|
||||
const resolved = await backendNodeToObject(nodeCdp, tabId, node.backendNodeId);
|
||||
// Keep the handle before checking cancellation so finally can release it.
|
||||
if (!isRpcError(resolved)) objectId = resolved;
|
||||
checkFocusAbort(deps.signal);
|
||||
if (isRpcError(resolved)) return resolved;
|
||||
|
||||
const runScript = async <T>(functionDeclaration: string): Promise<T | undefined> => {
|
||||
const reply = await nodeCdp.send<FocusScriptReply<T>>(
|
||||
target.tabId,
|
||||
"Runtime.callFunctionOn",
|
||||
{
|
||||
objectId,
|
||||
functionDeclaration,
|
||||
returnByValue: true,
|
||||
},
|
||||
);
|
||||
checkFocusAbort(deps.signal);
|
||||
if (reply.exceptionDetails) {
|
||||
const details = reply.exceptionDetails;
|
||||
throw new Error(
|
||||
`${action} script failed: ${details.exception?.description ?? details.text ?? "unknown exception"}`,
|
||||
);
|
||||
}
|
||||
return reply.result?.value;
|
||||
};
|
||||
|
||||
let wasFocused: boolean | undefined;
|
||||
if (action === "focus") {
|
||||
await nodeCdp.send(tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
||||
checkFocusAbort(deps.signal);
|
||||
} else {
|
||||
const mutation = await runScript<{ ok: boolean; was_focused: boolean }>(BLUR_TARGET);
|
||||
if (typeof mutation?.ok !== "boolean" || typeof mutation.was_focused !== "boolean") {
|
||||
throw new Error("blur script returned an unexpected result");
|
||||
}
|
||||
if (!mutation.ok) {
|
||||
return { code: "invalid_params", message: "target element does not support blur()" };
|
||||
}
|
||||
wasFocused = mutation.was_focused;
|
||||
}
|
||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||
tab_id: target.tabId,
|
||||
// Use a separate call so microtasks from focus/blur handlers finish before
|
||||
// we report success. In particular, a blur handler can restore focus.
|
||||
const focused = await runScript<boolean>(FOCUS_CHECK);
|
||||
if (typeof focused !== "boolean") {
|
||||
throw new Error(`${action} verification returned an unexpected result`);
|
||||
}
|
||||
if (focused !== (action === "focus")) {
|
||||
throw new Error(
|
||||
action === "focus"
|
||||
? "target element did not become focused"
|
||||
: "target element remained focused after blur()",
|
||||
);
|
||||
}
|
||||
return attachDialogs(deps.cdp, tabId, dialogCursor, {
|
||||
tab_id: tabId,
|
||||
used_ref: node.usedRef,
|
||||
used_selector: node.usedSelector,
|
||||
was_focused: mutation.was_focused,
|
||||
focused: false,
|
||||
focused,
|
||||
...(action === "blur" ? { was_focused: wasFocused! } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
|
||||
return (
|
||||
throwIfAborted(deps.signal) ?? {
|
||||
code: "cdp_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (objectId !== undefined && cleanupCdp && tabId !== undefined) {
|
||||
// Cleanup must also run after cancellation. Navigation may have already
|
||||
// disposed the object, which must not replace the operation's result.
|
||||
try {
|
||||
await cleanupCdp.send(tabId, "Runtime.releaseObject", { objectId });
|
||||
} catch {
|
||||
// The target or execution context may no longer exist.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
},
|
||||
"focused": {
|
||||
"description": "Whether the target is the document's deep active element after the call.",
|
||||
"description": "Whether the target holds DOM focus in its document or shadow root after the call.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tab_id": {
|
||||
|
||||
@@ -164,7 +164,7 @@ pub struct FocusResult {
|
||||
pub used_ref: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub used_selector: Option<String>,
|
||||
/// Whether the target is the document's deep active element after the call.
|
||||
/// Whether the target holds DOM focus in its document or shadow root after the call.
|
||||
pub focused: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dialogs: Vec<JavaScriptDialogInfo>,
|
||||
|
||||
Reference in New Issue
Block a user