fix(tools): stop browser automation from stealing focus in an attached browser
Attaching over app.cdp_url points automation at a browser the user is driving, so two behaviors that are correct for a browser we own are wrong there. pickElectronTarget enumerated CDP targets and took the first usable one, which is not necessarily the tab in front of the user, and #captureScreenshot always called page.bringToFront(), which switches the user's visible tab and pulls window focus on every screenshot. Connected browsers now prefer a tab that reports document.visibilityState "visible" and skip the pre-capture activation, accepting the compositor stall risk that activation avoids. Headless and spawned browsers are unchanged.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed browser automation disrupting a browser it attached to over `app.cdp_url`: the tool now adopts the tab the user actually has in the foreground and no longer raises its own tab when taking a screenshot. Owned and headless browsers keep activating the target before capture.
|
||||
|
||||
## [17.1.8] - 2026-07-28
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -122,8 +122,12 @@ export async function findReusableCdp(
|
||||
* Pick the best page target on an attached browser. Prefer discoverable page
|
||||
* targets first so Chromium/Edge attach flows that hide pages from
|
||||
* `browser.pages()` can still return a usable tab.
|
||||
*
|
||||
* `preferVisible` is for attaching to a browser a human is using: among equally
|
||||
* usable tabs, take the one that is actually foregrounded rather than whichever
|
||||
* target CDP happens to enumerate first.
|
||||
*/
|
||||
export async function pickElectronTarget(browser: Browser, matcher?: string): Promise<Page> {
|
||||
export async function pickElectronTarget(browser: Browser, matcher?: string, preferVisible = false): Promise<Page> {
|
||||
const discoveredPages = await Promise.all(
|
||||
browser.targets().map(async target => {
|
||||
if (String(target.type()) !== "page") return null;
|
||||
@@ -132,14 +136,14 @@ export async function pickElectronTarget(browser: Browser, matcher?: string): Pr
|
||||
);
|
||||
const usablePages = discoveredPages.filter((page): page is Page => page !== null);
|
||||
if (usablePages.length > 0) {
|
||||
return pickPageFromList(usablePages, matcher);
|
||||
return pickPageFromList(usablePages, matcher, preferVisible);
|
||||
}
|
||||
|
||||
const fallbackPages = await browser.pages();
|
||||
if (!fallbackPages.length) {
|
||||
throw new ToolError("No page targets available on the attached browser");
|
||||
}
|
||||
return pickPageFromList(fallbackPages, matcher);
|
||||
return pickPageFromList(fallbackPages, matcher, preferVisible);
|
||||
}
|
||||
|
||||
async function enrichPages(pages: Page[]): Promise<Array<{ page: Page; url: string; title: string }>> {
|
||||
@@ -152,7 +156,7 @@ async function enrichPages(pages: Page[]): Promise<Array<{ page: Page; url: stri
|
||||
);
|
||||
}
|
||||
|
||||
async function pickPageFromList(pages: Page[], matcher?: string): Promise<Page> {
|
||||
async function pickPageFromList(pages: Page[], matcher?: string, preferVisible = false): Promise<Page> {
|
||||
const enriched = await enrichPages(pages);
|
||||
if (matcher) {
|
||||
const needle = matcher.toLowerCase();
|
||||
@@ -161,10 +165,24 @@ async function pickPageFromList(pages: Page[], matcher?: string): Promise<Page>
|
||||
const summary = enriched.map(p => `- ${p.title || "(untitled)"} ${p.url}`).join("\n");
|
||||
throw new ToolError(`No page target matched ${JSON.stringify(matcher)}. Available pages:\n${summary}`);
|
||||
}
|
||||
return (
|
||||
enriched.find(p => !ATTACH_TARGET_SKIP_PATTERN.test(p.url) && !ATTACH_TARGET_SKIP_PATTERN.test(p.title))?.page ??
|
||||
enriched[0]!.page
|
||||
const usable = enriched.filter(
|
||||
p => !ATTACH_TARGET_SKIP_PATTERN.test(p.url) && !ATTACH_TARGET_SKIP_PATTERN.test(p.title),
|
||||
);
|
||||
if (preferVisible && usable.length > 1) {
|
||||
// Best-effort foreground probe; a tab that cannot answer counts as hidden.
|
||||
const visibility = await Promise.all(
|
||||
usable.map(async p => {
|
||||
try {
|
||||
return (await p.page.evaluate(() => document.visibilityState === "visible")) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const foreground = visibility.indexOf(true);
|
||||
if (foreground >= 0) return usable[foreground]!.page;
|
||||
}
|
||||
return usable[0]?.page ?? enriched[0]!.page;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,6 +65,11 @@ export type WorkerInitPayload =
|
||||
* previously force-killed the tab). Never set for first-time Electron attach.
|
||||
*/
|
||||
recover?: boolean;
|
||||
/**
|
||||
* Whether the worker may raise this tab before capturing a screenshot. Unset
|
||||
* behaves as `true`; the supervisor clears it for browsers we did not launch.
|
||||
*/
|
||||
activateForScreenshot?: boolean;
|
||||
};
|
||||
|
||||
export type ToolReply = { ok: true; value: unknown } | { ok: false; error: RunErrorPayload };
|
||||
|
||||
@@ -684,7 +684,11 @@ async function buildInitPayload(browser: PuppeteerBrowserHandle, opts: AcquireTa
|
||||
timeoutMs: opts.timeoutMs,
|
||||
};
|
||||
}
|
||||
const page = await pickElectronTarget(browser.browser, opts.target);
|
||||
// A "connected" browser is one the user is driving themselves (cdp attach), so the
|
||||
// tab we adopt and the focus we take are visible to them; owned Electron/spawned
|
||||
// browsers keep the previous behavior.
|
||||
const userDriven = browser.kind.kind === "connected";
|
||||
const page = await pickElectronTarget(browser.browser, opts.target, userDriven);
|
||||
const targetId = await targetIdForPage(page);
|
||||
return {
|
||||
mode: "attach",
|
||||
@@ -692,6 +696,7 @@ async function buildInitPayload(browser: PuppeteerBrowserHandle, opts: AcquireTa
|
||||
safeDir,
|
||||
targetId,
|
||||
dialogs: opts.dialogs,
|
||||
activateForScreenshot: !userDriven,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -793,6 +798,7 @@ async function recycleTimedOutWorkerTab(tab: WorkerTabSession, timeoutMs: number
|
||||
// Unblock a wedged page (open JS dialog, hung navigation) before adopting it —
|
||||
// otherwise init stalls, times out, and the tab gets force-killed.
|
||||
recover: true,
|
||||
activateForScreenshot: tab.kindTag !== "connected",
|
||||
};
|
||||
let worker = await spawnTabWorker();
|
||||
try {
|
||||
|
||||
@@ -72,6 +72,7 @@ declare global {
|
||||
var innerHeight: number;
|
||||
var document: {
|
||||
elementFromPoint(x: number, y: number): Element | null;
|
||||
readonly visibilityState: "visible" | "hidden";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -732,6 +733,7 @@ export class WorkerCore {
|
||||
#runtime: JsRuntime | null = null;
|
||||
#unsub: () => void;
|
||||
#mode?: WorkerInitPayload["mode"];
|
||||
#activateForScreenshot = true;
|
||||
#dialogPolicy?: DialogPolicy;
|
||||
#dialogHandler?: (dialog: Dialog) => void;
|
||||
#openDialog?: OpenDialogInfo;
|
||||
@@ -780,6 +782,7 @@ export class WorkerCore {
|
||||
async #init(payload: WorkerInitPayload): Promise<void> {
|
||||
try {
|
||||
this.#mode = payload.mode;
|
||||
this.#activateForScreenshot = payload.mode === "headless" || payload.activateForScreenshot !== false;
|
||||
const puppeteer = await loadPuppeteerInWorker(payload.safeDir);
|
||||
this.#browser = await puppeteer.connect({
|
||||
browserWSEndpoint: payload.browserWSEndpoint,
|
||||
@@ -1521,11 +1524,17 @@ export class WorkerCore {
|
||||
const page = this.#requirePage();
|
||||
// Multiple tabs can share one Chromium (sibling headless tabs on a shared
|
||||
// endpoint, cdp/app attach). CDP `Page.captureScreenshot` reads the
|
||||
// compositor surface, which follows the *active* target — a backgrounded
|
||||
// compositor surface, which follows the *active* target: a backgrounded
|
||||
// page can stall waiting for a fresh frame (the 20s screenshot timeouts)
|
||||
// or hand back a sibling tab's pixels. Activate first; best-effort so an
|
||||
// already-active or freshly-closed target never fails the capture.
|
||||
await untilAborted(signal, () => page.bringToFront()).catch(() => undefined);
|
||||
//
|
||||
// Browsers we do not own are the exception: raising a tab in the human's
|
||||
// running Chrome switches their visible tab and pulls window focus, so a
|
||||
// connected browser stays backgrounded and takes the stall risk instead.
|
||||
if (this.#activateForScreenshot) {
|
||||
await untilAborted(signal, () => page.bringToFront()).catch(() => undefined);
|
||||
}
|
||||
const fullPage = opts.selector ? false : (opts.fullPage ?? false);
|
||||
const captureType = "png";
|
||||
const captureMime = "image/png" as const;
|
||||
|
||||
@@ -6,12 +6,14 @@ import type { Browser, Page, Target } from "puppeteer-core";
|
||||
interface FakePageOptions {
|
||||
url: string;
|
||||
title: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
function fakePage(options: FakePageOptions): Page {
|
||||
return {
|
||||
url: () => options.url,
|
||||
title: async () => options.title,
|
||||
evaluate: async () => options.visible === true,
|
||||
} as unknown as Page;
|
||||
}
|
||||
|
||||
@@ -60,6 +62,29 @@ describe("pickElectronTarget", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("prefers the foreground tab when asked to, without disturbing default order", async () => {
|
||||
const background = fakePage({ url: "https://example.com/", title: "Example" });
|
||||
const foreground = fakePage({ url: "https://example.org/", title: "Example Org", visible: true });
|
||||
const browser = {
|
||||
targets: () => [fakeTarget("page", background), fakeTarget("page", foreground)],
|
||||
pages: async () => [],
|
||||
} as unknown as Browser;
|
||||
|
||||
await expect(pickElectronTarget(browser, undefined, true)).resolves.toBe(foreground);
|
||||
await expect(pickElectronTarget(browser)).resolves.toBe(background);
|
||||
});
|
||||
|
||||
test("falls back to the first usable tab when no tab reports itself visible", async () => {
|
||||
const first = fakePage({ url: "https://example.com/", title: "Example" });
|
||||
const second = fakePage({ url: "https://example.org/", title: "Example Org" });
|
||||
const browser = {
|
||||
targets: () => [fakeTarget("page", first), fakeTarget("page", second)],
|
||||
pages: async () => [],
|
||||
} as unknown as Browser;
|
||||
|
||||
await expect(pickElectronTarget(browser, undefined, true)).resolves.toBe(first);
|
||||
});
|
||||
|
||||
test("rejects websocket cdp_url values with an actionable diagnostic", () => {
|
||||
expect(() => normalizeConnectedCdpUrl("ws://127.0.0.1:9222/devtools/browser/id")).toThrow(
|
||||
"browser app.cdp_url must be the HTTP CDP discovery endpoint",
|
||||
|
||||
Reference in New Issue
Block a user