From a76fbc3b9b82038ab44c04d6379cc2d45e5cecd8 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Sat, 14 Nov 2026 09:15:15 +0000 Subject: tabs: one window for every agent; inactive tabs take input; background off by default Measured in Brave on 2026-11-14: an inactive tab takes trusted CDP clicks, key presses, text and mouse moves, and Page.captureScreenshot renders it live. Only mouseWheel hangs, waiting for a frame. The upstream guard that refused input to every hidden page was wrong for inactive tabs, and it is what made a window per session look necessary. - New sessions' tabs open inactive in the user's last focused window, in the session's tab group; a window is created only when none is open. - New groups start collapsed, unless their tab is the one showing, and expand when a tab in them is brought forward. Existing groups keep the user's setting. - Input is refused up front only in a minimized window (untested there). - chrome_scroll scrolls hidden pages by script instead of the wheel. - Focus emulation on attach, so blur-sensitive menus stay open. - Background mode is off by default. Calls still default to background; background:false or chrome_tab activate brings a tab forward. - Agent guidance, tool descriptions and docs say so. --- test-suite/unit/hidden-input.test.mjs | 98 +++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 39 deletions(-) (limited to 'test-suite/unit/hidden-input.test.mjs') diff --git a/test-suite/unit/hidden-input.test.mjs b/test-suite/unit/hidden-input.test.mjs index 874b5e5..b49229c 100644 --- a/test-suite/unit/hidden-input.test.mjs +++ b/test-suite/unit/hidden-input.test.mjs @@ -1,6 +1,8 @@ -// Hidden-tab input guard, full-screen automation targets, input.debug without a target, upload -// targets (label/wrapper/iframe/file-chooser), and screenshot pruning. Chrome and pages are mocked; -// the hidden-page behavior itself (Chrome drops trusted input) was measured live on Chrome/macOS. +// Hidden-tab input, automation targets beside a full-screen window, input.debug without a target, +// upload targets (label/wrapper/iframe/file-chooser), and screenshot pruning. Chrome and pages are +// mocked. Measured live in Brave on 2026-11-14: an inactive tab takes trusted clicks, keys, text and +// mouse moves and renders screenshots; a mouseWheel there hangs. Minimized windows are untested, so +// input there still fails up front. import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; @@ -54,50 +56,68 @@ function worker({ visibility = ["visible"], tab = { id: 2, windowId: 1, active: return { w, chrome, calls, inputs: () => calls.filter((c) => c.method.startsWith("Input.")) }; } -// ---- hidden-tab guard ---- -test("typing into a hidden tab fails fast with the reason instead of silently dropping keys", async () => { +// ---- hidden-tab input ---- +const minimized = { id: 1, state: "minimized" }; + +test("typing into an inactive tab sends trusted input", async () => { const h = worker({ visibility: ["hidden"] }); - await assert.rejects(h.w.dispatch("page.type", { selector: "#q", text: "hi", background: true }), (error) => { - assert.match(error.message, /chrome\.type: tab 2 is hidden because it is an inactive tab in its window/); - assert.match(error.message, /background:false/); - return true; - }); - assert.equal(h.inputs().length, 0, "no trusted input sent to a hidden page"); + const result = await h.w.dispatch("page.type", { selector: "#q", text: "hi", background: true }); + assert.ok(h.inputs().length > 0, "trusted input sent to the inactive tab"); + assert.notEqual(result.input, "dom-fallback"); }); -for (const [action, params] of [["key", { key: "Enter" }], ["hover", { x: 1, y: 1 }], ["scroll", { deltaY: 10 }], ["tap", { x: 1, y: 1 }], ["drag", { fromX: 1, fromY: 1, toX: 2, toY: 2 }]]) { - test(`${action} refuses hidden tabs before sending input`, async () => { +for (const [action, params] of [["key", { key: "Enter" }], ["hover", { x: 1, y: 1 }], ["tap", { x: 1, y: 1 }], ["drag", { fromX: 1, fromY: 1, toX: 2, toY: 2 }]]) { + test(`${action} sends trusted input to an inactive tab and refuses a minimized window`, async () => { const h = worker({ visibility: ["hidden"] }); - await assert.rejects(h.w.dispatch(`page.${action}`, { ...params, background: true }), new RegExp(`chrome\\.${action}: tab 2 is hidden`)); - assert.equal(h.inputs().length, 0); + let moves = 0; + h.w.cdpMoveTo = async () => { moves++; }; // hover is only a mouse move, stubbed by the harness + await h.w.dispatch(`page.${action}`, { ...params, background: true }); + assert.ok(h.inputs().length + moves > 0); + const m = worker({ visibility: ["hidden"], win: minimized }); + await assert.rejects(m.w.dispatch(`page.${action}`, { ...params, background: true }), new RegExp(`chrome\\.${action}: tab 2 is hidden because its window is minimized`)); + assert.equal(m.inputs().length, 0); }); } -test("click and fill use their DOM fallback immediately for hidden tabs, or reject with domFallback:false", async () => { - const click = worker({ visibility: ["hidden"] }); +test("scroll on an inactive tab scrolls by script, since Chrome never answers its wheel events", async () => { + const h = worker({ visibility: ["hidden"] }); + const result = await h.w.dispatch("page.scroll", { deltaY: 300, background: true }); + assert.equal(result.input, "script"); + assert.equal(h.inputs().length, 0, "no mouseWheel sent"); + assert.ok(h.calls.some((c) => c.method === "Runtime.evaluate" && /scrollBy/.test(c.params.expression))); + const visible = worker(); + assert.equal((await visible.w.dispatch("page.scroll", { deltaY: 300, background: true })).input, "chrome"); +}); + +test("click and fill send trusted input to an inactive tab; in a minimized window they use their DOM fallback, or reject with domFallback:false", async () => { + const inactive = worker({ visibility: ["hidden"] }); + assert.equal((await inactive.w.dispatch("page.click", { selector: "button", background: true })).input, "chrome"); + assert.ok(inactive.inputs().length > 0); + + const click = worker({ visibility: ["hidden"], win: minimized }); const clicked = await click.w.dispatch("page.click", { selector: "button", background: true }); assert.equal(clicked.input, "dom-fallback"); - assert.match(clicked.reason, /hidden/); + assert.match(clicked.reason, /minimized/); assert.equal(click.inputs().length, 0); - await assert.rejects(worker({ visibility: ["hidden"] }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/); + await assert.rejects(worker({ visibility: ["hidden"], win: minimized }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/); - const fill = worker({ visibility: ["hidden"] }); + const fill = worker({ visibility: ["hidden"], win: minimized }); fill.w.domFillFallback = async (_tabId, _params, cause) => ({ input: "dom-fallback", reason: String(cause.message) }); const filled = await fill.w.dispatch("page.fill", { selector: "#q", text: "x", background: true }); assert.equal(filled.input, "dom-fallback"); - assert.match(filled.reason, /hidden/); + assert.match(filled.reason, /minimized/); assert.equal(fill.inputs().length, 0); }); -test("hidden reasons distinguish minimized and macOS full-screen windows", async () => { - const minimized = worker({ visibility: ["hidden"], win: { id: 1, state: "minimized" } }); - await assert.rejects(minimized.w.dispatch("page.key", { key: "a", background: true }), /its window is minimized/); - const fullscreen = worker({ visibility: ["hidden"], tab: { id: 2, windowId: 1, active: true, url: "https://fixture.test/" }, win: { id: 1, state: "fullscreen" } }); - await assert.rejects(fullscreen.w.dispatch("page.key", { key: "a", background: true }), (error) => { - assert.match(error.message, /behind a full-screen window/); - assert.equal(error.windowState, "fullscreen"); +test("a hidden page in a full-screen or covered window still takes input; only minimized is refused", async () => { + const m = worker({ visibility: ["hidden"], win: minimized }); + await assert.rejects(m.w.dispatch("page.key", { key: "a", background: true }), (error) => { + assert.match(error.message, /its window is minimized/); + assert.equal(error.windowState, "minimized"); return true; }); + const fullscreen = worker({ visibility: ["hidden"], tab: { id: 2, windowId: 1, active: true, url: "https://fixture.test/" }, win: { id: 1, state: "fullscreen" } }); + assert.equal((await fullscreen.w.dispatch("page.key", { key: "a", background: true })).input, "chrome"); }); test("visible pages, failed probes, and foreground tabs that become visible proceed with trusted input", async () => { @@ -105,20 +125,18 @@ test("visible pages, failed probes, and foreground tabs that become visible proc assert.equal((await visible.w.dispatch("page.key", { key: "a", background: true })).input, "chrome"); const unknown = worker({ visibility: [new Error("probe failed")] }); assert.equal((await unknown.w.dispatch("page.key", { key: "a", background: true })).input, "chrome"); - const foreground = worker({ visibility: ["hidden", "hidden", "visible"] }); + const foreground = worker({ visibility: ["hidden", "hidden", "visible"], win: minimized }); foreground.w.bringToFront = async (tab) => tab; assert.equal((await foreground.w.dispatch("page.key", { key: "a", foreground: true })).input, "chrome"); }); // ---- automation target placement ---- -test("automation targets get their own window even while the user's window is full screen", async () => { - // Chrome disables macOS automatic window tabbing, so a new window is never merged into the user's - // full-screen window; putting Pi's tab in the user's window would only lose isolation. +test("automation targets go in the user's window, inactive, even while it is full screen", async () => { for (const state of ["fullscreen", "maximized"]) { const h = worker({ win: { id: 1, state } }); await h.w.createAutomationTarget(`s-${state}`, undefined); - assert.deepEqual(clone(h.calls.find((c) => c.method === "windows.create").props), { url: "about:blank", focused: false }, state); - assert.equal(h.calls.some((c) => c.method === "tabs.create"), false, state); + assert.deepEqual(clone(h.calls.find((c) => c.method === "tabs.create").props), { url: "about:blank", active: false, windowId: 1 }, state); + assert.equal(h.calls.some((c) => c.method === "windows.create"), false, state); } }); @@ -137,8 +155,8 @@ test("input.debug without a target does not call tabs.get(-1) and reports the se }); // ---- upload targets ---- -function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true } = {}) { - const h = worker({ visibility: [visibility] }); +function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true, win } = {}) { + const h = worker({ visibility: [visibility], ...(win ? { win } : {}) }); const fileInput = { tagName: "INPUT", type: "file", multiple: true, isConnected: true }; const frameInput = { tagName: "INPUT", type: "file", multiple: false, isConnected: true }; const button = { tagName: "BUTTON", isConnected: true, querySelectorAll: () => [] }; @@ -209,13 +227,15 @@ test("upload buttons open an intercepted file chooser with a trusted click on vi assert.ok(h.inputs().some((c) => c.params.type === "mousePressed")); }); -test("hidden pages use a user-gesture element.click() for upload buttons unless domFallback:false", async () => { - const h = uploadWorker({ visibility: "hidden" }); +test("upload buttons in a minimized window use a user-gesture element.click() unless domFallback:false", async () => { + const inactive = uploadWorker({ visibility: "hidden" }); + assert.equal((await inactive.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true })).trigger, "chrome", "an inactive tab gets the trusted click"); + const h = uploadWorker({ visibility: "hidden", win: minimized }); const result = await h.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true }); assert.equal(result.trigger, "activation-click"); assert.match(result.reason, /hidden/); assert.equal(h.inputs().length, 0); - const strict = uploadWorker({ visibility: "hidden" }); + const strict = uploadWorker({ visibility: "hidden", win: minimized }); await assert.rejects(strict.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true, domFallback: false }), /is hidden/); assert.equal(setFiles(strict).length, 0); }); -- cgit v1.2.3