Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {});
Expand All @@ -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",
Expand All @@ -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();
});

Expand Down
5 changes: 5 additions & 0 deletions apps/extension/src/browser-driver/chromium-cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// Returning a tab clears the cache before Chrome finishes detaching.
// New observers must wait before opening the next connection to that tab.
Expand Down
22 changes: 15 additions & 7 deletions apps/extension/src/tools/__tests__/click.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {},
Expand All @@ -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);
Expand Down Expand Up @@ -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` });
Expand All @@ -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 },
Expand Down
105 changes: 105 additions & 0 deletions apps/extension/src/tools/__tests__/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions apps/extension/src/tools/__tests__/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,7 @@ describe("click input readiness", () => {
rendered?: () => boolean | Promise<boolean>;
defaultTimeoutMs?: number;
onCommand?: (method: string, params: Record<string, unknown>) => void;
ownsBackgroundExecution?: boolean;
} = {},
) {
const manager = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
9 changes: 9 additions & 0 deletions apps/extension/src/tools/input-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ export async function withInputReady<T extends object>(
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",
Expand All @@ -160,6 +163,12 @@ export async function withInputReady<T extends object>(
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;
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/src/tools/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface CdpRunner {
getAttachmentId?(tabId: number): string | undefined;
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
acquireBackgroundExecution?(sessionId: string, tabId: number): Promise<void>;
/** 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<void>;
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
Expand Down