aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit/hidden-input.test.mjs
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-14 09:15:15 +0000
committerIgor Soarez <igor@soarez.org>2026-11-14 09:15:34 +0000
commita76fbc3b9b82038ab44c04d6379cc2d45e5cecd8 (patch)
tree7cf7425cfbca05a99b42198f78ff3fcf1a6eb5d8 /test-suite/unit/hidden-input.test.mjs
parente18559a1240219b977ffc7da2076c09cd96d1dad (diff)
tabs: one window for every agent; inactive tabs take input; background off by defaultHEADmaster
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.
Diffstat (limited to 'test-suite/unit/hidden-input.test.mjs')
-rw-r--r--test-suite/unit/hidden-input.test.mjs98
1 files changed, 59 insertions, 39 deletions
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);
});