diff --git a/apps/extension/src/tools/__tests__/interaction.test.ts b/apps/extension/src/tools/__tests__/interaction.test.ts index 8184b6dc..2206f3d9 100644 --- a/apps/extension/src/tools/__tests__/interaction.test.ts +++ b/apps/extension/src/tools/__tests__/interaction.test.ts @@ -1511,6 +1511,71 @@ describe("handleFill", () => { expect(callFns).toHaveLength(5); }); + it("routes fill typing into the ref's OOPIF child session", async () => { + // DOM.focus already resolves the node in its own session; the typing + // that follows must use the same session, or the text lands in the + // main frame while the OOPIF field stays empty (issue #438). + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const ctx = await sm.start("aa11"); + ctx.refStore.set("e3", 1234, { + tabId: 4, + frameId: "child-frame", + cdpSessionId: "child-session", + }); + const fake = makeFakeCdp({ + "DOM.scrollIntoViewIfNeeded": () => ({}), + "Runtime.evaluate": () => ({ result: { value: { width: 200, height: 100 } } }), + "Page.getLayoutMetrics": () => ({ + cssLayoutViewport: { clientWidth: 200, clientHeight: 100 }, + }), + }); + fake.cdp.getFrameGraph = vi.fn(async () => ({ + rootFrameId: "main", + frames: [ + { frameId: "main", target: { tabId: 4 } }, + { + frameId: "child-frame", + parentFrameId: "main", + ownerBackendNodeId: 99, + target: { tabId: 4, sessionId: "child-session" }, + }, + ], + })); + const targetCalls: Array<{ sessionId?: string; method: string }> = []; + fake.cdp.sendToTarget = vi.fn(async (target, method, params) => { + targetCalls.push({ sessionId: target.sessionId, method }); + if (method === "DOM.describeNode") { + return { + node: { backendNodeId: 1234, nodeName: "INPUT", attributes: ["type", "text"] }, + }; + } + if (method === "DOM.scrollIntoViewIfNeeded") return {}; + if (method === "DOM.focus") return {}; + if (method === "DOM.resolveNode") return { object: { objectId: "obj-oopif" } }; + if (method === "Runtime.callFunctionOn") return successfulFillScript(params); + if (method === "Runtime.releaseObject") return {}; + if (method === "Input.insertText") return {}; + if (method === "Runtime.evaluate") return { result: { value: { width: 200, height: 100 } } }; + if (method === "Page.getLayoutMetrics") { + return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } }; + } + throw new Error(`unexpected child CDP call ${method}`); + }) as CdpRunner["sendToTarget"]; + + const res = await handleFill( + sm, + { session_id: "aa11", ref: "@e3", value: "hello" }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ); + if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`); + expect(res.value_length).toBe(5); + const childTyping = targetCalls.filter( + (c) => c.method === "Input.insertText" || c.method === "Input.dispatchKeyEvent", + ); + expect(childTyping.map((c) => c.sessionId)).toContain("child-session"); + expect(fake.sent.filter((c) => c.method.startsWith("Input."))).toHaveLength(0); + }); + it("passes clear_before=false to preparation and verifies the result", async () => { const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const ctx = await sm.start("aa11"); @@ -1772,6 +1837,66 @@ describe("handlePress", () => { expect(res.key).toBe("Enter"); }); + it("routes press key events into the ref's OOPIF child session", async () => { + // A ref inside an OOPIF carries cdpSessionId; key events must reach + // that child session (which owns the focused element), not the root + // one the tab-level send hits (issue #438). + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const ctx = await sm.start("aa11"); + ctx.refStore.set("e3", 1234, { + tabId: 4, + frameId: "child-frame", + cdpSessionId: "child-session", + }); + const fake = makeFakeCdp({ + "Input.dispatchKeyEvent": () => ({}), + "DOM.scrollIntoViewIfNeeded": () => ({}), + "Runtime.evaluate": () => ({ result: { value: { width: 200, height: 100 } } }), + "Page.getLayoutMetrics": () => ({ + cssLayoutViewport: { clientWidth: 200, clientHeight: 100 }, + }), + }); + fake.cdp.getFrameGraph = vi.fn(async () => ({ + rootFrameId: "main", + frames: [ + { frameId: "main", target: { tabId: 4 } }, + { + frameId: "child-frame", + parentFrameId: "main", + ownerBackendNodeId: 99, + target: { tabId: 4, sessionId: "child-session" }, + }, + ], + })); + const targetCalls: Array<{ sessionId?: string; method: string }> = []; + 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 === "Input.dispatchKeyEvent") return {}; + if (method === "Runtime.evaluate") return { result: { value: { width: 200, height: 100 } } }; + if (method === "Page.getLayoutMetrics") { + return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } }; + } + throw new Error(`unexpected child CDP call ${method}`); + }) as CdpRunner["sendToTarget"]; + + const res = await handlePress( + sm, + { session_id: "aa11", key: "Enter", ref: "@e3" }, + { cdp: fake.cdp, tabsApi: fake.tabsApi }, + ); + expectPressOk(res); + // Every key event went to the child session; none to the root. + const childKeyEvents = targetCalls.filter((c) => c.method === "Input.dispatchKeyEvent"); + expect(childKeyEvents.map((c) => c.sessionId)).toEqual([ + "child-session", + "child-session", + "child-session", + ]); + expect(fake.sent.filter((c) => c.method === "Input.dispatchKeyEvent")).toHaveLength(0); + }); + it.each([ "focus", "key", diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts index a1889d98..5df8aeb8 100644 --- a/apps/extension/src/tools/interaction.ts +++ b/apps/extension/src/tools/interaction.ts @@ -1278,12 +1278,12 @@ export async function handleFill( // end command can escape a nested editing host. Place its range below. // Neither event carries an End key that could invoke a page shortcut. try { - await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { + await nodeCdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", commands: native ? ["moveToEndOfDocument"] : [], }); } finally { - await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" }); + await nodeCdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" }); } if (throwIfAborted(deps.signal)) { return { code: "cancelled", message: "fill aborted" }; @@ -1309,7 +1309,7 @@ export async function handleFill( if (params.value !== "") { // Normalize the actual input as well as the expected value: older Chrome // versions retain CR in editors or turn CRLF into two textarea breaks. - await deps.cdp.send(target.tabId, "Input.insertText", { + await nodeCdp.send(target.tabId, "Input.insertText", { text: params.value.replace(/\r\n?/g, "\n"), }); } @@ -1568,6 +1568,10 @@ export async function handlePress( ? await resolveBackendNode(deps.cdp, ctx, target, params, "press") : undefined; if (node && isRpcError(node)) return node; + // Key events must reach the focused element's session: for a ref inside + // an OOPIF that is the child session, not the root one the tab-level + // send would hit (issue #438). Without a ref the root session is correct. + const keyCdp = node ? cdpRunnerForTarget(deps.cdp, node.cdpTarget) : deps.cdp; return withInputReady(ctx, target.tabId, { ...deps, deadline }, async (input) => { // Optional focus before key dispatch. if (node) { @@ -1607,7 +1611,7 @@ export async function handlePress( deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); deps.onInputSent?.(target.tabId); input.markSent(); - await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { + await keyCdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: descriptor.key, code: descriptor.code, @@ -1621,7 +1625,7 @@ export async function handlePress( typeof descriptor.text === "string" && descriptor.text.length > 0 ) { - await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { + await keyCdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "char", key: descriptor.key, code: descriptor.code, @@ -1639,7 +1643,7 @@ export async function handlePress( } } deps.onInputSent?.(target.tabId); - await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { + await keyCdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: descriptor.key, code: descriptor.code,