mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(vom): support canvas reading
This commit is contained in:
@@ -34,6 +34,15 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
|||||||
if (!h && method === "Page.getLayoutMetrics") {
|
if (!h && method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!h &&
|
||||||
|
method === "Runtime.evaluate" &&
|
||||||
|
String((params as { expression?: string })?.expression ?? "").includes(
|
||||||
|
"bsk-focus-target-probe",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { result: { value: { hasFocus: true, activeTag: "input" } } };
|
||||||
|
}
|
||||||
if (!h) throw new Error(`unexpected CDP call ${method}`);
|
if (!h) throw new Error(`unexpected CDP call ${method}`);
|
||||||
return h(params);
|
return h(params);
|
||||||
};
|
};
|
||||||
@@ -45,6 +54,10 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
|||||||
params?: object,
|
params?: object,
|
||||||
) => Promise<T>,
|
) => Promise<T>,
|
||||||
trackSessionTab: vi.fn(),
|
trackSessionTab: vi.fn(),
|
||||||
|
getFrameGraph: vi.fn(async (tabId) => ({
|
||||||
|
rootFrameId: "main",
|
||||||
|
frames: [{ frameId: "main", target: { tabId } }],
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
const tabsApi = {
|
const tabsApi = {
|
||||||
get: vi.fn(
|
get: vi.fn(
|
||||||
@@ -55,6 +68,16 @@ function makeFakeCdp(handlers: Record<string, (params: unknown) => unknown>) {
|
|||||||
return { cdp, tabsApi, sent };
|
return { cdp, tabsApi, sent };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fillRuntimeReadback(value: string) {
|
||||||
|
return (params: unknown) => {
|
||||||
|
const fn = String((params as { functionDeclaration?: string }).functionDeclaration ?? "");
|
||||||
|
if (fn.includes("bsk-input-readback")) {
|
||||||
|
return { result: { value: { supported: true, value } } };
|
||||||
|
}
|
||||||
|
return { result: { type: "undefined" } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function makeSurfaceClickFixture(onMouse?: (params: Record<string, unknown>) => void) {
|
async function makeSurfaceClickFixture(onMouse?: (params: Record<string, unknown>) => void) {
|
||||||
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const context = await manager.start("aa11");
|
const context = await manager.start("aa11");
|
||||||
@@ -733,7 +756,7 @@ describe("handleFill", () => {
|
|||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
|
||||||
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
"Runtime.callFunctionOn": fillRuntimeReadback("hello"),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -766,6 +789,9 @@ describe("handleFill", () => {
|
|||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
|
||||||
"Runtime.callFunctionOn": (p) => {
|
"Runtime.callFunctionOn": (p) => {
|
||||||
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
|
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
|
||||||
|
if (fn.includes("bsk-input-readback")) {
|
||||||
|
return { result: { value: { supported: true, value: "existingx" } } };
|
||||||
|
}
|
||||||
// No "this.value = ''" clearing on a no-clear path; only the
|
// No "this.value = ''" clearing on a no-clear path; only the
|
||||||
// post-input dispatchEvent.
|
// post-input dispatchEvent.
|
||||||
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
|
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
|
||||||
@@ -779,7 +805,7 @@ describe("handleFill", () => {
|
|||||||
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||||
);
|
);
|
||||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
expect(res.value_length).toBe(1);
|
expect(res.value_length).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats contenteditable=true as fillable", async () => {
|
it("treats contenteditable=true as fillable", async () => {
|
||||||
@@ -797,7 +823,7 @@ describe("handleFill", () => {
|
|||||||
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
"DOM.focus": () => ({}),
|
"DOM.focus": () => ({}),
|
||||||
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
|
||||||
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
|
"Runtime.callFunctionOn": fillRuntimeReadback("rich"),
|
||||||
"Input.insertText": () => ({}),
|
"Input.insertText": () => ({}),
|
||||||
});
|
});
|
||||||
const res = await handleFill(
|
const res = await handleFill(
|
||||||
@@ -808,6 +834,88 @@ describe("handleFill", () => {
|
|||||||
expect("code" in res).toBe(false);
|
expect("code" in res).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes OOPIF focus, text, DOM events, and readback through the node target", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.refStore.set("e1", 77, {
|
||||||
|
tabId: 4,
|
||||||
|
frameId: "child-frame",
|
||||||
|
cdpSessionId: "child-session",
|
||||||
|
});
|
||||||
|
const fake = makeFakeCdp({ "DOM.scrollIntoViewIfNeeded": () => ({}) });
|
||||||
|
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||||
|
rootFrameId: "main",
|
||||||
|
frames: [
|
||||||
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
|
{
|
||||||
|
frameId: "child-frame",
|
||||||
|
parentFrameId: "main",
|
||||||
|
ownerBackendNodeId: 12,
|
||||||
|
target: { tabId: 4, sessionId: "child-session" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const childCalls: string[] = [];
|
||||||
|
fake.cdp.sendToTarget = vi.fn(async (target, method, params) => {
|
||||||
|
expect(target).toEqual({ tabId: 4, sessionId: "child-session" });
|
||||||
|
childCalls.push(method);
|
||||||
|
if (method === "DOM.describeNode") {
|
||||||
|
return { node: { backendNodeId: 77, nodeName: "INPUT", attributes: [] } };
|
||||||
|
}
|
||||||
|
if (method === "DOM.scrollIntoViewIfNeeded" || method === "DOM.focus") return {};
|
||||||
|
if (method === "DOM.resolveNode") return { object: { objectId: "child-input" } };
|
||||||
|
if (method === "Input.insertText") {
|
||||||
|
expect(params).toEqual({ text: "中文输入" });
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (method === "Runtime.callFunctionOn") {
|
||||||
|
const fn = String((params as { functionDeclaration?: string }).functionDeclaration ?? "");
|
||||||
|
return fn.includes("bsk-input-readback")
|
||||||
|
? { result: { value: { supported: true, value: "中文输入" } } }
|
||||||
|
: { result: { type: "undefined" } };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected child call ${method}`);
|
||||||
|
}) as CdpRunner["sendToTarget"];
|
||||||
|
|
||||||
|
const res = await handleFill(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", ref: "@e1", value: "中文输入" },
|
||||||
|
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("code" in res) throw new Error(JSON.stringify(res));
|
||||||
|
expect(res.value_length).toBe(4);
|
||||||
|
expect(childCalls).toContain("DOM.focus");
|
||||||
|
expect(childCalls).toContain("Input.insertText");
|
||||||
|
expect(childCalls.filter((method) => method === "Runtime.callFunctionOn")).toHaveLength(4);
|
||||||
|
expect(fake.sent.some((call) => call.method === "Input.insertText")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns input_not_applied when a controlled input resets the value", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.refStore.set("e1", 8, { tabId: 4 });
|
||||||
|
const fake = makeFakeCdp({
|
||||||
|
"DOM.describeNode": () => ({ node: { backendNodeId: 8, nodeName: "INPUT" } }),
|
||||||
|
"DOM.scrollIntoViewIfNeeded": () => ({}),
|
||||||
|
"DOM.focus": () => ({}),
|
||||||
|
"DOM.resolveNode": () => ({ object: { objectId: "controlled" } }),
|
||||||
|
"Input.insertText": () => ({}),
|
||||||
|
"Runtime.callFunctionOn": fillRuntimeReadback(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await handleFill(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", ref: "@e1", value: "rejected" },
|
||||||
|
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "input_not_applied", expected_value_length: 8, actual_value_length: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("stops before Input.insertText when abort fires after clearing", async () => {
|
it("stops before Input.insertText when abort fires after clearing", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
@@ -1002,6 +1110,125 @@ describe("handlePress", () => {
|
|||||||
expect(res.key).toBe("Enter");
|
expect(res.key).toBe("Enter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes ref-targeted key events through the OOPIF session", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.refStore.set("e1", 555, {
|
||||||
|
tabId: 4,
|
||||||
|
frameId: "child-frame",
|
||||||
|
cdpSessionId: "child-session",
|
||||||
|
});
|
||||||
|
const fake = makeFakeCdp({ "DOM.scrollIntoViewIfNeeded": () => ({}) });
|
||||||
|
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||||
|
rootFrameId: "main",
|
||||||
|
frames: [
|
||||||
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
|
{
|
||||||
|
frameId: "child-frame",
|
||||||
|
parentFrameId: "main",
|
||||||
|
ownerBackendNodeId: 12,
|
||||||
|
target: { tabId: 4, sessionId: "child-session" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const childCalls: string[] = [];
|
||||||
|
fake.cdp.sendToTarget = vi.fn(async (_target, method) => {
|
||||||
|
childCalls.push(method);
|
||||||
|
return {};
|
||||||
|
}) as CdpRunner["sendToTarget"];
|
||||||
|
|
||||||
|
const res = await handlePress(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", key: "Enter", ref: "@e1" },
|
||||||
|
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||||
|
);
|
||||||
|
|
||||||
|
expectPressOk(res);
|
||||||
|
expect(childCalls).toEqual([
|
||||||
|
"DOM.scrollIntoViewIfNeeded",
|
||||||
|
"DOM.focus",
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
]);
|
||||||
|
expect(fake.sent.some((call) => call.method === "Input.dispatchKeyEvent")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes an untargeted press to the uniquely focused OOPIF session", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
await sm.start("aa11");
|
||||||
|
const fake = makeFakeCdp({
|
||||||
|
"Runtime.evaluate": () => ({
|
||||||
|
result: { value: { hasFocus: true, activeTag: "iframe" } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||||
|
rootFrameId: "main",
|
||||||
|
frames: [
|
||||||
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
|
{
|
||||||
|
frameId: "child-frame",
|
||||||
|
parentFrameId: "main",
|
||||||
|
target: { tabId: 4, sessionId: "child-session" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const childCalls: string[] = [];
|
||||||
|
fake.cdp.sendToTarget = vi.fn(async (_target, method) => {
|
||||||
|
childCalls.push(method);
|
||||||
|
if (method === "Runtime.evaluate") {
|
||||||
|
return { result: { value: { hasFocus: true, activeTag: "textarea" } } };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}) as CdpRunner["sendToTarget"];
|
||||||
|
|
||||||
|
const res = await handlePress(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", key: "a" },
|
||||||
|
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||||
|
);
|
||||||
|
|
||||||
|
expectPressOk(res);
|
||||||
|
expect(childCalls).toEqual([
|
||||||
|
"Runtime.evaluate",
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an untargeted press when focus ownership is ambiguous", async () => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
await sm.start("aa11");
|
||||||
|
const fake = makeFakeCdp({
|
||||||
|
"Runtime.evaluate": () => ({
|
||||||
|
result: { value: { hasFocus: true, activeTag: "input" } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
fake.cdp.getFrameGraph = vi.fn(async () => ({
|
||||||
|
rootFrameId: "main",
|
||||||
|
frames: [
|
||||||
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
|
{ frameId: "child", target: { tabId: 4, sessionId: "child-session" } },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
fake.cdp.sendToTarget = vi.fn(async () => ({
|
||||||
|
result: { value: { hasFocus: true, activeTag: "textarea" } },
|
||||||
|
})) as CdpRunner["sendToTarget"];
|
||||||
|
|
||||||
|
const res = await handlePress(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", key: "Enter" },
|
||||||
|
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res).toMatchObject({
|
||||||
|
code: "cdp_failed",
|
||||||
|
data: { reason: "focus_target_unresolved", candidate_count: 2 },
|
||||||
|
});
|
||||||
|
expect(fake.sent.some((call) => call.method === "Input.dispatchKeyEvent")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("stops before focus when abort fires after press scroll", async () => {
|
it("stops before focus when abort fires after press scroll", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
// commands.
|
// commands.
|
||||||
|
|
||||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||||
import type { CdpTarget } from "@/browser-driver/frame-graph";
|
import { type CdpTarget, cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||||
import type {
|
import type {
|
||||||
ClickParams,
|
ClickParams,
|
||||||
@@ -217,6 +217,108 @@ async function resolveBackendNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InputDispatchContext {
|
||||||
|
target: CdpTarget;
|
||||||
|
cdp: CdpRunner;
|
||||||
|
backendNodeId?: number;
|
||||||
|
frameId?: string;
|
||||||
|
usedRef?: string;
|
||||||
|
usedSelector?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputContextForNode(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
node: Awaited<ReturnType<typeof resolveBackendNode>>,
|
||||||
|
): (InputDispatchContext & { backendNodeId: number }) | RpcError {
|
||||||
|
if (isRpcError(node)) return node;
|
||||||
|
return {
|
||||||
|
target: node.cdpTarget,
|
||||||
|
cdp: cdpRunnerForTarget(cdp, node.cdpTarget),
|
||||||
|
backendNodeId: node.backendNodeId,
|
||||||
|
frameId: node.frameId,
|
||||||
|
usedRef: node.usedRef,
|
||||||
|
usedSelector: node.usedSelector,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FocusProbe {
|
||||||
|
hasFocus: boolean;
|
||||||
|
activeTag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve keyboard input to the CDP target that currently owns focus. */
|
||||||
|
async function resolveFocusedInputContext(
|
||||||
|
cdp: CdpRunner,
|
||||||
|
tabId: number,
|
||||||
|
): Promise<InputDispatchContext | RpcError> {
|
||||||
|
if (!cdp.getFrameGraph) {
|
||||||
|
return rpcError(
|
||||||
|
"cdp_failed",
|
||||||
|
"focus_target_unresolved",
|
||||||
|
"press cannot determine the focused frame target",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let targets: CdpTarget[];
|
||||||
|
try {
|
||||||
|
const graph = await cdp.getFrameGraph(tabId);
|
||||||
|
const unique = new Map<string, CdpTarget>();
|
||||||
|
unique.set(cdpTargetKey({ tabId }), { tabId });
|
||||||
|
for (const frame of graph.frames) unique.set(cdpTargetKey(frame.target), frame.target);
|
||||||
|
targets = [...unique.values()];
|
||||||
|
} catch (err) {
|
||||||
|
return rpcError(
|
||||||
|
"cdp_failed",
|
||||||
|
"focus_target_unresolved",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const focused: Array<{ target: CdpTarget; probe: FocusProbe }> = [];
|
||||||
|
await Promise.all(
|
||||||
|
targets.map(async (candidate) => {
|
||||||
|
try {
|
||||||
|
const reply = await cdpRunnerForTarget(cdp, candidate).send<{
|
||||||
|
result?: { value?: FocusProbe };
|
||||||
|
}>(tabId, "Runtime.evaluate", {
|
||||||
|
expression: `(() => {
|
||||||
|
/* bsk-focus-target-probe */
|
||||||
|
const active = document.activeElement;
|
||||||
|
return {
|
||||||
|
hasFocus: document.hasFocus(),
|
||||||
|
activeTag: active instanceof Element ? active.tagName.toLowerCase() : ''
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
const probe = reply.result?.value;
|
||||||
|
if (probe?.hasFocus) focused.push({ target: candidate, probe });
|
||||||
|
} catch {
|
||||||
|
// A target may navigate or detach while the graph is being probed.
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A top document whose active element is an iframe delegates focus to
|
||||||
|
// that frame. Removing those delegating targets leaves the deepest OOPIF
|
||||||
|
// target while same-process frames continue to use the shared root target.
|
||||||
|
const owners = focused.filter(
|
||||||
|
({ probe }) => probe.activeTag !== "iframe" && probe.activeTag !== "frame",
|
||||||
|
);
|
||||||
|
if (owners.length !== 1) {
|
||||||
|
return rpcError(
|
||||||
|
"cdp_failed",
|
||||||
|
"focus_target_unresolved",
|
||||||
|
owners.length === 0
|
||||||
|
? "press found no unique focused frame target"
|
||||||
|
: "press found multiple focused frame targets",
|
||||||
|
{ candidate_count: owners.length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const target = owners[0].target;
|
||||||
|
return { target, cdp: cdpRunnerForTarget(cdp, target) };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// tool.click
|
// tool.click
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -618,8 +720,9 @@ export async function handleFill(
|
|||||||
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
const dialogCursor = markDialogCursor(deps.cdp, target.tabId);
|
||||||
|
|
||||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "fill");
|
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "fill");
|
||||||
if (isRpcError(node)) return node;
|
const input = inputContextForNode(deps.cdp, node);
|
||||||
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
if (isRpcError(input)) return input;
|
||||||
|
const nodeCdp = input.cdp;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
@@ -627,7 +730,7 @@ export async function handleFill(
|
|||||||
target.tabId,
|
target.tabId,
|
||||||
"DOM.describeNode",
|
"DOM.describeNode",
|
||||||
{
|
{
|
||||||
backendNodeId: node.backendNodeId,
|
backendNodeId: input.backendNodeId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!described.node || !isFillable(described.node)) {
|
if (!described.node || !isFillable(described.node)) {
|
||||||
@@ -640,15 +743,15 @@ export async function handleFill(
|
|||||||
const scrollErr = await scrollElementAndFramesIntoView(
|
const scrollErr = await scrollElementAndFramesIntoView(
|
||||||
deps.cdp,
|
deps.cdp,
|
||||||
target.tabId,
|
target.tabId,
|
||||||
node.cdpTarget,
|
input.target,
|
||||||
node.backendNodeId,
|
input.backendNodeId,
|
||||||
node.frameId,
|
input.frameId,
|
||||||
);
|
);
|
||||||
if (scrollErr) return scrollErr;
|
if (scrollErr) return scrollErr;
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: input.backendNodeId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
@@ -660,7 +763,7 @@ export async function handleFill(
|
|||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, node.backendNodeId);
|
const objectIdOrErr = await backendNodeToObject(nodeCdp, target.tabId, input.backendNodeId);
|
||||||
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
|
if (isRpcError(objectIdOrErr)) return objectIdOrErr;
|
||||||
const objectId = objectIdOrErr;
|
const objectId = objectIdOrErr;
|
||||||
const clearBefore = params.clear_before ?? true;
|
const clearBefore = params.clear_before ?? true;
|
||||||
@@ -691,7 +794,7 @@ export async function handleFill(
|
|||||||
}
|
}
|
||||||
// CDP `Input.insertText` handles IME / multi-byte input out of the
|
// CDP `Input.insertText` handles IME / multi-byte input out of the
|
||||||
// box, much more reliably than per-key `dispatchKeyEvent`.
|
// box, much more reliably than per-key `dispatchKeyEvent`.
|
||||||
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
|
await nodeCdp.send(target.tabId, "Input.insertText", { text: params.value });
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "fill aborted" };
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
}
|
}
|
||||||
@@ -704,6 +807,28 @@ export async function handleFill(
|
|||||||
}`,
|
}`,
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
});
|
});
|
||||||
|
// Give controlled inputs one bounded browser-render turn to accept or
|
||||||
|
// reject the dispatched value before reporting success.
|
||||||
|
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function() {
|
||||||
|
/* bsk-input-sync */
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, 100);
|
||||||
|
if (typeof requestAnimationFrame !== 'function') { finish(); return; }
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(finish));
|
||||||
|
});
|
||||||
|
}`,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
@@ -711,11 +836,56 @@ export async function handleFill(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (throwIfAborted(deps.signal)) {
|
||||||
|
return { code: "cancelled", message: "fill aborted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualValue: string;
|
||||||
|
try {
|
||||||
|
const readback = await nodeCdp.send<{
|
||||||
|
result?: { value?: { supported?: boolean; value?: string } };
|
||||||
|
}>(target.tabId, "Runtime.callFunctionOn", {
|
||||||
|
objectId,
|
||||||
|
functionDeclaration: `function() {
|
||||||
|
/* bsk-input-readback */
|
||||||
|
if (this instanceof HTMLInputElement || this instanceof HTMLTextAreaElement) {
|
||||||
|
return { supported: true, value: this.value };
|
||||||
|
}
|
||||||
|
if (this.isContentEditable) {
|
||||||
|
return { supported: true, value: this.innerText ?? this.textContent ?? '' };
|
||||||
|
}
|
||||||
|
return { supported: false, value: '' };
|
||||||
|
}`,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
const state = readback.result?.value;
|
||||||
|
actualValue = typeof state?.value === "string" ? state.value : "";
|
||||||
|
const applied =
|
||||||
|
state?.supported === true &&
|
||||||
|
(clearBefore
|
||||||
|
? actualValue === params.value
|
||||||
|
: params.value.length === 0 || actualValue.includes(params.value));
|
||||||
|
if (!applied) {
|
||||||
|
return rpcError(
|
||||||
|
"cdp_failed",
|
||||||
|
"input_not_applied",
|
||||||
|
"the page did not retain the requested input value",
|
||||||
|
{ expected_value_length: params.value.length, actual_value_length: actualValue.length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return rpcError(
|
||||||
|
"cdp_failed",
|
||||||
|
"input_not_applied",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
|
||||||
tab_id: target.tabId,
|
tab_id: target.tabId,
|
||||||
used_ref: node.usedRef,
|
used_ref: input.usedRef,
|
||||||
used_selector: node.usedSelector,
|
used_selector: input.usedSelector,
|
||||||
value_length: params.value.length,
|
value_length: actualValue.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,31 +1060,38 @@ export async function handlePress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let input: InputDispatchContext | RpcError;
|
||||||
// Optional focus before key dispatch.
|
// Optional focus before key dispatch.
|
||||||
if (params.ref || params.selector) {
|
if (params.ref || params.selector) {
|
||||||
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "press");
|
const node = await resolveBackendNode(deps.cdp, ctx, target, params, "press");
|
||||||
if (isRpcError(node)) return node;
|
const explicitInput = inputContextForNode(deps.cdp, node);
|
||||||
const nodeCdp = cdpRunnerForTarget(deps.cdp, node.cdpTarget);
|
if (isRpcError(explicitInput)) return explicitInput;
|
||||||
|
input = explicitInput;
|
||||||
try {
|
try {
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
const scrollErr = await scrollElementAndFramesIntoView(
|
const scrollErr = await scrollElementAndFramesIntoView(
|
||||||
deps.cdp,
|
deps.cdp,
|
||||||
target.tabId,
|
target.tabId,
|
||||||
node.cdpTarget,
|
explicitInput.target,
|
||||||
node.backendNodeId,
|
explicitInput.backendNodeId,
|
||||||
node.frameId,
|
explicitInput.frameId,
|
||||||
);
|
);
|
||||||
if (scrollErr) return scrollErr;
|
if (scrollErr) return scrollErr;
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
return { code: "cancelled", message: "press aborted" };
|
return { code: "cancelled", message: "press aborted" };
|
||||||
}
|
}
|
||||||
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
|
await explicitInput.cdp.send(target.tabId, "DOM.focus", {
|
||||||
|
backendNodeId: explicitInput.backendNodeId,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
code: "cdp_failed",
|
code: "cdp_failed",
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
input = await resolveFocusedInputContext(deps.cdp, target.tabId);
|
||||||
|
if (isRpcError(input)) return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throwIfAborted(deps.signal)) {
|
if (throwIfAborted(deps.signal)) {
|
||||||
@@ -928,7 +1105,7 @@ export async function handlePress(
|
|||||||
try {
|
try {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
|
||||||
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||||
type: "rawKeyDown",
|
type: "rawKeyDown",
|
||||||
key: descriptor.key,
|
key: descriptor.key,
|
||||||
code: descriptor.code,
|
code: descriptor.code,
|
||||||
@@ -942,7 +1119,7 @@ export async function handlePress(
|
|||||||
typeof descriptor.text === "string" &&
|
typeof descriptor.text === "string" &&
|
||||||
descriptor.text.length > 0
|
descriptor.text.length > 0
|
||||||
) {
|
) {
|
||||||
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||||
type: "char",
|
type: "char",
|
||||||
key: descriptor.key,
|
key: descriptor.key,
|
||||||
code: descriptor.code,
|
code: descriptor.code,
|
||||||
@@ -959,7 +1136,7 @@ export async function handlePress(
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
await input.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
|
||||||
type: "keyUp",
|
type: "keyUp",
|
||||||
key: descriptor.key,
|
key: descriptor.key,
|
||||||
code: descriptor.code,
|
code: descriptor.code,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export type RpcErrorReason =
|
|||||||
| "ref_not_found"
|
| "ref_not_found"
|
||||||
| "selector_not_found"
|
| "selector_not_found"
|
||||||
| "target_not_fillable"
|
| "target_not_fillable"
|
||||||
|
| "focus_target_unresolved"
|
||||||
|
| "input_not_applied"
|
||||||
| "target_not_select"
|
| "target_not_select"
|
||||||
| "option_not_found"
|
| "option_not_found"
|
||||||
| "single_select_value_count"
|
| "single_select_value_count"
|
||||||
|
|||||||
@@ -93,6 +93,29 @@ Run with a `900x700` Agent Window.
|
|||||||
- `fixture-below-fold` is absent initially.
|
- `fixture-below-fold` is absent initially.
|
||||||
- After scrolling it into view and observing again, `fixture-below-fold` must be represented with a fresh ref.
|
- After scrolling it into view and observing again, `fixture-below-fold` must be represented with a fresh ref.
|
||||||
|
|
||||||
|
### `input-frames.html`
|
||||||
|
|
||||||
|
This page exercises generic frame-aware input routing. It contains a top-level
|
||||||
|
input plus same-origin, cross-origin (OOPIF), and nested frame inputs. Each
|
||||||
|
frame includes an input, textarea, contenteditable editor, and a controlled
|
||||||
|
input that resets its value on the next animation frame.
|
||||||
|
|
||||||
|
- `fill` must route focus, text insertion, DOM events, and readback through the
|
||||||
|
ref's owning CDP target.
|
||||||
|
- `press --ref` must focus and dispatch every key event through that same target.
|
||||||
|
- Untargeted `press` must follow the actual focused target or refuse when focus
|
||||||
|
ownership cannot be determined uniquely.
|
||||||
|
- The rejecting input must return `input_not_applied`, never a successful
|
||||||
|
requested-value length.
|
||||||
|
|
||||||
|
### `canvas-input.html`
|
||||||
|
|
||||||
|
This is the PR3 end-to-end fixture. Its cross-origin frame contains a
|
||||||
|
visual-only three-row Canvas. A screenshot-bound click on row 3 exposes and
|
||||||
|
focuses a standard textbox; ordinary `fill` must then write and read back its
|
||||||
|
real value through the textbox's owning target. The fixture deliberately does
|
||||||
|
not provide a Canvas-specific fill action.
|
||||||
|
|
||||||
### `dynamic.html`
|
### `dynamic.html`
|
||||||
|
|
||||||
Use the normal DOM buttons and re-observe after each action:
|
Use the normal DOM buttons and re-observe after each action:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Canvas to standard input fixture</title>
|
||||||
|
<style>
|
||||||
|
body { padding: 16px; font: 14px system-ui, sans-serif; }
|
||||||
|
iframe { width: 300px; height: 220px; border: 2px solid #5d6d7e; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Canvas to standard input fixture</h1>
|
||||||
|
<p>Select row 3 in the visual-only Canvas, then fill the exposed standard textbox.</p>
|
||||||
|
<iframe
|
||||||
|
title="cross-origin Canvas editor"
|
||||||
|
src="http://localhost:4173/frame.html?label=canvas-input-grid&interactive=1&editor=1"
|
||||||
|
></iframe>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,6 +19,18 @@
|
|||||||
if (params.get("canvas") === "0") canvas.remove();
|
if (params.get("canvas") === "0") canvas.remove();
|
||||||
else {
|
else {
|
||||||
canvas.setAttribute("aria-label", params.get("label") || "fixture-frame-default");
|
canvas.setAttribute("aria-label", params.get("label") || "fixture-frame-default");
|
||||||
|
if (params.get("editor") === "1") {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.font = "14px system-ui, sans-serif";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.strokeStyle = "#1e8449";
|
||||||
|
context.fillStyle = "#145a32";
|
||||||
|
for (let row = 0; row < 3; row += 1) {
|
||||||
|
const top = row * 30;
|
||||||
|
context.strokeRect(0.5, top + 0.5, 219, 29);
|
||||||
|
context.fillText(`Row ${row + 1}`, 12, top + 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (params.get("interactive") === "1") {
|
if (params.get("interactive") === "1") {
|
||||||
const status = document.createElement("p");
|
const status = document.createElement("p");
|
||||||
status.id = "pointer-status";
|
status.id = "pointer-status";
|
||||||
@@ -39,6 +51,22 @@
|
|||||||
canvas.addEventListener("click", () => {
|
canvas.addEventListener("click", () => {
|
||||||
status.textContent += "; click received";
|
status.textContent += "; click received";
|
||||||
});
|
});
|
||||||
|
if (params.get("editor") === "1") {
|
||||||
|
canvas.addEventListener("click", (event) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const canvasY = ((event.clientY - rect.top) / rect.height) * canvas.height;
|
||||||
|
const row = Math.min(2, Math.max(0, Math.floor(canvasY / 30)));
|
||||||
|
status.textContent += `; selected row=${row + 1}`;
|
||||||
|
document.querySelector("#canvas-cell-editor")?.remove();
|
||||||
|
if (row !== 2) return;
|
||||||
|
const editor = document.createElement("input");
|
||||||
|
editor.id = "canvas-cell-editor";
|
||||||
|
editor.setAttribute("aria-label", "selected canvas cell editor");
|
||||||
|
editor.placeholder = "Type into selected row 3";
|
||||||
|
canvas.insertAdjacentElement("afterend", editor);
|
||||||
|
editor.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (params.get("nested") === "1") {
|
if (params.get("nested") === "1") {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 12px; font: 14px system-ui, sans-serif; }
|
||||||
|
label, textarea, input, [contenteditable] { display: block; margin: 8px 0; }
|
||||||
|
input, textarea, [contenteditable] { width: 240px; padding: 6px; border: 1px solid #85929e; }
|
||||||
|
iframe { width: 280px; height: 190px; border: 2px solid #85929e; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<label>Frame input <input aria-label="frame input" /></label>
|
||||||
|
<label>Frame textarea <textarea aria-label="frame textarea"></textarea></label>
|
||||||
|
<div contenteditable="true" aria-label="frame editor"></div>
|
||||||
|
<label>Rejecting input <input id="rejecting" aria-label="rejecting input" /></label>
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const rejecting = document.querySelector("#rejecting");
|
||||||
|
rejecting.addEventListener("input", () => requestAnimationFrame(() => { rejecting.value = ""; }));
|
||||||
|
if (params.get("nested") === "1") {
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.title = "nested input frame";
|
||||||
|
iframe.src = "input-frame.html";
|
||||||
|
document.body.append(iframe);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Frame input routing matrix</title>
|
||||||
|
<style>
|
||||||
|
body { padding: 16px; font: 14px system-ui, sans-serif; }
|
||||||
|
.frames { display: grid; grid-template-columns: repeat(3, 320px); gap: 16px; }
|
||||||
|
iframe { width: 300px; height: 430px; border: 2px solid #5d6d7e; }
|
||||||
|
input { padding: 6px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Frame input routing matrix</h1>
|
||||||
|
<label>Top input <input aria-label="top input" /></label>
|
||||||
|
<div class="frames">
|
||||||
|
<iframe title="same-origin inputs" src="input-frame.html"></iframe>
|
||||||
|
<iframe title="cross-origin inputs" src="http://localhost:4173/input-frame.html"></iframe>
|
||||||
|
<iframe title="nested inputs" src="input-frame.html?nested=1"></iframe>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -44,6 +44,8 @@ pub mod reason {
|
|||||||
pub const REF_NOT_FOUND: &str = "ref_not_found";
|
pub const REF_NOT_FOUND: &str = "ref_not_found";
|
||||||
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
|
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
|
||||||
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
|
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
|
||||||
|
pub const FOCUS_TARGET_UNRESOLVED: &str = "focus_target_unresolved";
|
||||||
|
pub const INPUT_NOT_APPLIED: &str = "input_not_applied";
|
||||||
pub const TARGET_NOT_SELECT: &str = "target_not_select";
|
pub const TARGET_NOT_SELECT: &str = "target_not_select";
|
||||||
pub const OPTION_NOT_FOUND: &str = "option_not_found";
|
pub const OPTION_NOT_FOUND: &str = "option_not_found";
|
||||||
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
|
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
|
||||||
@@ -234,6 +236,20 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
|
|||||||
),
|
),
|
||||||
exit_code: base.exit_code,
|
exit_code: base.exit_code,
|
||||||
},
|
},
|
||||||
|
(ErrorCode::CdpFailed, reason::FOCUS_TARGET_UNRESOLVED) => RenderInfo {
|
||||||
|
summary: "focused frame target could not be determined",
|
||||||
|
hint: Some(
|
||||||
|
"pass an explicit --ref or --selector so the key event can be routed safely",
|
||||||
|
),
|
||||||
|
exit_code: base.exit_code,
|
||||||
|
},
|
||||||
|
(ErrorCode::CdpFailed, reason::INPUT_NOT_APPLIED) => RenderInfo {
|
||||||
|
summary: "the page did not retain the requested input",
|
||||||
|
hint: Some(
|
||||||
|
"refresh the snapshot and retry; the page may be rejecting or resetting controlled input",
|
||||||
|
),
|
||||||
|
exit_code: base.exit_code,
|
||||||
|
},
|
||||||
(ErrorCode::PermissionDenied, reason::REF_CAPABILITY_DENIED) => RenderInfo {
|
(ErrorCode::PermissionDenied, reason::REF_CAPABILITY_DENIED) => RenderInfo {
|
||||||
summary: "snapshot ref does not support this operation",
|
summary: "snapshot ref does not support this operation",
|
||||||
hint: Some(
|
hint: Some(
|
||||||
@@ -532,4 +548,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(info.hint.unwrap().contains("exactly one"));
|
assert!(info.hint.unwrap().contains("exactly one"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_target_unresolved_overrides_cdp_copy() {
|
||||||
|
let data = serde_json::json!({ "reason": reason::FOCUS_TARGET_UNRESOLVED });
|
||||||
|
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
|
||||||
|
assert_eq!(info.summary, "focused frame target could not be determined");
|
||||||
|
assert!(info.hint.unwrap().contains("--ref"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_not_applied_overrides_cdp_copy() {
|
||||||
|
let data = serde_json::json!({ "reason": reason::INPUT_NOT_APPLIED });
|
||||||
|
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
|
||||||
|
assert_eq!(info.summary, "the page did not retain the requested input");
|
||||||
|
assert!(info.hint.unwrap().contains("rejecting"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user