diff --git a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts index 3f5fb6ed..9e9250b7 100644 --- a/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts +++ b/apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts @@ -1229,7 +1229,7 @@ describe("document-bound references", () => { }); describe("controlled background execution", () => { it("does not emulate passive reads and releases control while retaining passive attachments", async () => { - const { api } = fakeApi(); + const { api, onDetach } = fakeApi(); const cdp = new ChromiumCdp(api); cdp.trackSessionTab("reader", 4); await cdp.send(4, "Runtime.evaluate", {}); @@ -1238,9 +1238,15 @@ describe("controlled background execution", () => { "Emulation.setFocusEmulationEnabled", expect.anything(), ); + expect(cdp.ownsBackgroundExecution("agent", 4)).toBe(false); await cdp.acquireBackgroundExecution("agent", 4); + expect(cdp.ownsBackgroundExecution("agent", 4)).toBe(true); + onDetach.fire({ tabId: 4 }, "canceled_by_user"); + expect(cdp.ownsBackgroundExecution("agent", 4)).toBe(true); + await cdp.ensureAttached(4); await cdp.acquireBackgroundExecution("agent", 4); await cdp.releaseSessionTab("agent", 4); + expect(cdp.ownsBackgroundExecution("agent", 4)).toBe(false); expect(api.sendCommand).toHaveBeenCalledWith( { tabId: 4 }, "Emulation.setFocusEmulationEnabled", @@ -1249,6 +1255,10 @@ describe("controlled background execution", () => { expect(api.detach).not.toHaveBeenCalled(); await cdp.detachSession("reader"); expect(api.detach).toHaveBeenCalledOnce(); + await cdp.acquireBackgroundExecution("closed", 5); + expect(cdp.ownsBackgroundExecution("closed", 5)).toBe(true); + onDetach.fire({ tabId: 5 }, "target_closed"); + expect(cdp.ownsBackgroundExecution("closed", 5)).toBe(false); cdp.dispose(); }); diff --git a/apps/extension/src/browser-driver/chromium-cdp.ts b/apps/extension/src/browser-driver/chromium-cdp.ts index 17e4cbb9..28826047 100644 --- a/apps/extension/src/browser-driver/chromium-cdp.ts +++ b/apps/extension/src/browser-driver/chromium-cdp.ts @@ -258,6 +258,11 @@ export class ChromiumCdp { } } + /** True when this session owns the persistent focus/visibility override for the tab. */ + ownsBackgroundExecution(sessionId: string, tabId: number): boolean { + return this.backgroundExecution.has(sessionId, tabId); + } + private async ensureRawAttached(tabId: number): Promise { // Returning a tab clears the cache before Chrome finishes detaching. // New observers must wait before opening the next connection to that tab. diff --git a/apps/extension/src/tools/__tests__/click.browser.test.ts b/apps/extension/src/tools/__tests__/click.browser.test.ts index 68eddb7b..b640d8b7 100644 --- a/apps/extension/src/tools/__tests__/click.browser.test.ts +++ b/apps/extension/src/tools/__tests__/click.browser.test.ts @@ -241,7 +241,7 @@ describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser click readiness", ( if (event.method === "DOM.documentUpdated") documentUpdates++; for (const listener of listeners) listener({ tabId: 4 }, event.method, event.params); }; - const readinessCommands: { method: string; elapsedMs?: number; state: string }[] = []; + const cdpCommands: { method: string; elapsedMs?: number; state: string }[] = []; const api: CdpDebuggerApi = { // page() already attached the root debugger session. attach: async () => {}, @@ -250,7 +250,7 @@ describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser click readiness", ( }, sendCommand: async (debuggee, method, params) => { const call = { method, state: "pending", elapsedMs: undefined as number | undefined }; - readinessCommands.push(call); + cdpCommands.push(call); const started = performance.now(); try { const result = await send(method, params, debuggee.sessionId ?? target.sessionId); @@ -314,17 +314,24 @@ describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser click readiness", ( expect(ref).toBeDefined(); expect(documentChanges).toBe(0); expect(await prepare("tool.click")).toBeUndefined(); - readinessCommands.length = 0; + expect(cdp.ownsBackgroundExecution(ctx.sessionId, 4)).toBe(true); + cdpCommands.length = 0; const clicked = await handleClick( manager, { session_id: ctx.sessionId, tab_id: 4, ref: ref! }, { cdp, tabsApi }, ); - expect( - clicked, - JSON.stringify({ clicked, commands: readinessCommands }), - ).not.toHaveProperty("code"); + expect(clicked, JSON.stringify({ clicked, commands: cdpCommands })).not.toHaveProperty( + "code", + ); expect(await target.evaluate("window.clicks")).toEqual([true]); + expect( + cdpCommands.some( + (command) => + command.method === "Emulation.setFocusEmulationEnabled" || + command.method === "Page.captureScreenshot", + ), + ).toBe(false); expect(documentChanges).toBe(0); expect(ctx.refStore.resolve(ref!, { tabId: 4 })).not.toBeNull(); await cdp.send(4, "Page.navigate", { url: `${url}/next` }); @@ -344,6 +351,7 @@ describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser click readiness", ( flatten: true, }); await cdp.detachSession(ctx.sessionId); + expect(cdp.ownsBackgroundExecution(ctx.sessionId, 4)).toBe(false); const focus = await send<{ result: { value: boolean } }>( "Runtime.evaluate", { expression: "document.hasFocus()", returnByValue: true }, diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index 6e88dba0..24bdf88a 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -240,6 +240,111 @@ describe("ToolDispatcher", () => { expect(sent[0]).toEqual({ id: "r-1", result: {} }); }); + it("uses the persistent background lease before dispatching a click", async () => { + const tab = { id: 7, windowId: 4242, active: true, url: "https://example.test" }; + 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: vi.fn(async () => ({ windowId: 4242, initialTabIds: [7] })), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => 7), + }, + }); + const ctx = await sessions.start("aa11"); + ctx.refStore.set("e1", 12, { tabId: 7 }); + const { transport, sent, deliver } = fakeTransport(); + const commands: string[] = []; + let leased = false; + let visibility = "visible"; + let visibilityReads = 0; + const cdp = { + acquireBackgroundExecution: vi.fn(async (sessionId: string, tabId: number) => { + expect([sessionId, tabId]).toEqual(["aa11", 7]); + commands.push("acquire"); + leased = true; + }), + ownsBackgroundExecution: vi.fn((sessionId: string, tabId: number) => { + expect([sessionId, tabId]).toEqual(["aa11", 7]); + commands.push("owns"); + return leased; + }), + trackSessionTab: vi.fn(), + send: vi.fn(async (_tabId: number, method: string, params?: object) => { + commands.push(method); + if ( + method === "Runtime.evaluate" && + (params as { expression?: string })?.expression === "document.visibilityState" + ) { + visibilityReads++; + return { result: { value: visibility } }; + } + if (method === "DOM.scrollIntoViewIfNeeded") return {}; + if (method === "DOM.getContentQuads") return { quads: [[0, 0, 40, 0, 40, 20, 0, 20]] }; + if (method === "DOM.getBoxModel") + return { model: { content: [0, 0, 40, 0, 40, 20, 0, 20] } }; + if (method === "Page.getLayoutMetrics") + return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } }; + if (method === "Runtime.evaluate") + return { + result: { + value: { overlayHostPresent: false, overlayHostConnected: false }, + }, + }; + if (method === "Input.dispatchMouseEvent") return {}; + throw new Error(`unexpected CDP call ${method}`); + }), + } as unknown as TestDispatcherCdp; + const dispatcher = new ToolDispatcher({ transport, sessions, cdp }); + dispatcher.start(); + + deliver(makeRequest("tool.click", { session_id: "aa11", ref: "e1" })); + await vi.waitFor(() => expect(sent).toHaveLength(1)); + + expect(sent[0]).toMatchObject({ result: { tab_id: 7, used_ref: "e1", x: 20, y: 10 } }); + expect(cdp.acquireBackgroundExecution).toHaveBeenCalledOnce(); + expect(cdp.ownsBackgroundExecution).toHaveBeenCalledWith("aa11", 7); + const acquireIndex = commands.indexOf("acquire"); + const ownsIndex = commands.indexOf("owns"); + expect(acquireIndex).toBeGreaterThanOrEqual(0); + expect(ownsIndex).toBeGreaterThanOrEqual(0); + expect(acquireIndex).toBeLessThan(ownsIndex); + expect(visibilityReads).toBe(1); + expect(commands).not.toContain("Emulation.setFocusEmulationEnabled"); + expect(commands).not.toContain("Page.captureScreenshot"); + expect( + vi.mocked(cdp.send).mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent"), + ).toHaveLength(3); + + visibility = "hidden"; + deliver(makeRequest("tool.click", { session_id: "aa11", ref: "e1" })); + await vi.waitFor(() => expect(sent).toHaveLength(2)); + expect(sent[1]).toMatchObject({ + error: { code: "cdp_failed", data: { reason: "input_not_ready", effect_state: "none" } }, + }); + expect(visibilityReads).toBe(2); + expect(commands).not.toContain("Emulation.setFocusEmulationEnabled"); + expect( + vi.mocked(cdp.send).mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent"), + ).toHaveLength(3); + + visibility = "visible"; + deliver(makeRequest("tool.click", { session_id: "aa11", ref: "e1" })); + await vi.waitFor(() => expect(sent).toHaveLength(3)); + expect(sent[2]).toMatchObject({ result: { tab_id: 7, used_ref: "e1" } }); + expect(visibilityReads).toBe(3); + expect(commands).not.toContain("Emulation.setFocusEmulationEnabled"); + expect( + vi.mocked(cdp.send).mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent"), + ).toHaveLength(6); + dispatcher.stop(); + }); + it("wires the production tab APIs into tool.session_stop so a surviving user tab releases the window", async () => { // Regression guard for the deps that session_stop reads directly: when // `tabManagement.tabs` / `tabsQuery` are not injected by the dispatcher, diff --git a/apps/extension/src/tools/__tests__/interaction.test.ts b/apps/extension/src/tools/__tests__/interaction.test.ts index 5d04d202..0dfdfdef 100644 --- a/apps/extension/src/tools/__tests__/interaction.test.ts +++ b/apps/extension/src/tools/__tests__/interaction.test.ts @@ -732,6 +732,7 @@ describe("click input readiness", () => { rendered?: () => boolean | Promise; defaultTimeoutMs?: number; onCommand?: (method: string, params: Record) => void; + ownsBackgroundExecution?: boolean; } = {}, ) { const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); @@ -754,6 +755,9 @@ describe("click input readiness", () => { ); let attachmentId: string | undefined = "original"; fake.cdp.getAttachmentId = () => attachmentId; + if (options.ownsBackgroundExecution !== undefined) { + fake.cdp.ownsBackgroundExecution = vi.fn(() => options.ownsBackgroundExecution as boolean); + } return { ...fake, ctx, @@ -785,6 +789,36 @@ describe("click input readiness", () => { expect(f.mouseCommands()).toHaveLength(3); }); + it("samples visibility without changing an existing persistent background lease", async () => { + const f = await fixture({ + ownsBackgroundExecution: true, + visibility: () => "visible", + }); + expect(await f.click()).not.toHaveProperty("code"); + expect(f.cdp.ownsBackgroundExecution).toHaveBeenCalledWith("aa11", 4); + expect( + f.sent.some( + (c) => + c.method === "Runtime.evaluate" && + (c.params as { expression?: string }).expression === "document.visibilityState", + ), + ).toBe(true); + expect(f.focusCommands()).toEqual([]); + expect(f.sent.some((c) => c.method === "Page.captureScreenshot")).toBe(false); + expect(f.mouseCommands()).toHaveLength(3); + }); + + it("rejects hidden leased input before dispatch without toggling its focus override", async () => { + const f = await fixture({ ownsBackgroundExecution: true, visibility: () => "hidden" }); + expect(await f.click()).toMatchObject({ + code: "cdp_failed", + data: { reason: "input_not_ready", effect_state: "none" }, + }); + expect(f.focusCommands()).toEqual([]); + expect(f.sent.some((c) => c.method === "Page.captureScreenshot")).toBe(false); + expect(f.mouseCommands()).toEqual([]); + }); + it("wakes even an active-but-hidden page before geometry, then restores after release", async () => { const f = await fixture(); expect(await f.click()).not.toHaveProperty("code"); diff --git a/apps/extension/src/tools/input-readiness.ts b/apps/extension/src/tools/input-readiness.ts index 9c1f68ea..e2ecea37 100644 --- a/apps/extension/src/tools/input-readiness.ts +++ b/apps/extension/src/tools/input-readiness.ts @@ -147,6 +147,9 @@ export async function withInputReady( try { checkActive(); deps.cdp.trackSessionTab?.(ctx.sessionId, tabId); + // A persistent lease owns focus emulation for dispatcher-controlled tools; + // unowned direct-handler paths retain the bounded 0.3.0 fallback below. + const persistentLease = deps.cdp.ownsBackgroundExecution?.(ctx.sessionId, tabId) === true; const visibility = await waitForInputReply( deps.cdp.send<{ result: { value?: string } }>(tabId, "Runtime.evaluate", { expression: "document.visibilityState", @@ -160,6 +163,12 @@ export async function withInputReady( if (cancelled) return cancelled; checkActive(); if (visibility.result.value === "hidden") { + if (persistentLease) + return { + code: "cdp_failed", + message: "Input target is hidden despite an owned background-execution lease", + data: { reason: "input_not_ready", effect_state: "none" }, + }; attachmentId = deps.cdp.getAttachmentId?.(tabId); // Mark ownership before awaiting: a failed reply may still have enabled it. restoreFocus = true; diff --git a/apps/extension/src/tools/shared.ts b/apps/extension/src/tools/shared.ts index 8e2f819c..1e06a3ca 100644 --- a/apps/extension/src/tools/shared.ts +++ b/apps/extension/src/tools/shared.ts @@ -70,6 +70,8 @@ export interface CdpRunner { getAttachmentId?(tabId: number): string | undefined; ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise; acquireBackgroundExecution?(sessionId: string, tabId: number): Promise; + /** Whether this session requests the persistent override, regardless of applied CDP state. */ + ownsBackgroundExecution?(sessionId: string, tabId: number): boolean; trackSessionTab?(sessionId: string, tabId: number): void; releaseSessionTab?(sessionId: string, tabId: number): Promise; onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {