aboutsummaryrefslogtreecommitdiff
path: root/test-suite/unit/hidden-input.test.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'test-suite/unit/hidden-input.test.mjs')
-rw-r--r--test-suite/unit/hidden-input.test.mjs279
1 files changed, 279 insertions, 0 deletions
diff --git a/test-suite/unit/hidden-input.test.mjs b/test-suite/unit/hidden-input.test.mjs
new file mode 100644
index 0000000..376db69
--- /dev/null
+++ b/test-suite/unit/hidden-input.test.mjs
@@ -0,0 +1,279 @@
+// 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.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import vm from "node:vm";
+import { stripTypeScriptTypes } from "node:module";
+import { test } from "node:test";
+
+const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
+const indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
+const clone = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
+
+function worker({ visibility = ["visible"], tab = { id: 2, windowId: 1, active: false, url: "https://fixture.test/" }, win = { id: 1, state: "normal" } } = {}) {
+ const calls = [];
+ const listener = { addListener() {}, removeListener() {} };
+ const chrome = {
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener },
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
+ debugger: { onDetach: listener, onEvent: listener, getTargets: (cb) => cb([]) },
+ storage: { session: { get: async () => ({}), set: async () => {} } },
+ scripting: { executeScript: async (options) => { calls.push({ method: "scripting.executeScript", options }); return [{ result: { tag: "BUTTON", url: tab.url } }]; } },
+ tabs: {
+ get: async (id) => (id === tab.id ? clone(tab) : Promise.reject(new Error(`No tab with id: ${id}`))),
+ query: async () => [clone(tab)],
+ create: async (props) => { calls.push({ method: "tabs.create", props }); return { id: 77, windowId: 1, url: props.url }; },
+ },
+ windows: {
+ get: async () => clone(win),
+ getLastFocused: async () => clone(win),
+ create: async (props) => { calls.push({ method: "windows.create", props }); return { id: 9, tabs: [{ id: 90, windowId: 9 }] }; },
+ },
+ };
+ const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
+ w.self = w;
+ vm.runInNewContext(workerSource, w);
+ w.sleep = async () => {};
+ w.getTabByParams = async () => clone(tab);
+ w.attachDebugger = async () => {};
+ w.cdpMoveTo = async () => {};
+ w.resolveTargetInTab = async () => ({ found: true, x: 40, y: 50, rect: { left: 10, top: 10, width: 100, height: 20 }, tag: "INPUT" });
+ let probes = 0;
+ w.cdp = async (_tabId, method, params = {}) => {
+ calls.push({ method, params: clone(params) });
+ if (method === "Runtime.evaluate" && params.expression === "document.visibilityState") {
+ const state = visibility[Math.min(probes++, visibility.length - 1)];
+ if (state instanceof Error) throw state;
+ return { result: { type: "string", value: state } };
+ }
+ return {};
+ };
+ 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 () => {
+ 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");
+});
+
+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 () => {
+ 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);
+ });
+}
+
+test("click and fill use their DOM fallback immediately for hidden tabs, or reject with domFallback:false", async () => {
+ const click = worker({ visibility: ["hidden"] });
+ const clicked = await click.w.dispatch("page.click", { selector: "button", background: true });
+ assert.equal(clicked.input, "dom-fallback");
+ assert.match(clicked.reason, /hidden/);
+ assert.equal(click.inputs().length, 0);
+ await assert.rejects(worker({ visibility: ["hidden"] }).w.dispatch("page.click", { selector: "button", background: true, domFallback: false }), /is hidden/);
+
+ const fill = worker({ visibility: ["hidden"] });
+ 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.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");
+ return true;
+ });
+});
+
+test("visible pages, failed probes, and foreground tabs that become visible proceed with trusted input", async () => {
+ const visible = worker();
+ 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"] });
+ 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 use a background tab, not a new window, while the user's window is full screen", async () => {
+ const full = worker({ win: { id: 1, state: "fullscreen" } });
+ await full.w.createAutomationTarget("s1", undefined);
+ assert.equal(full.calls.some((c) => c.method === "windows.create"), false);
+ assert.deepEqual(clone(full.calls.find((c) => c.method === "tabs.create").props), { url: "about:blank", active: false });
+
+ const normal = worker({ win: { id: 1, state: "maximized" } });
+ await normal.w.createAutomationTarget("s2", undefined);
+ assert.deepEqual(clone(normal.calls.find((c) => c.method === "windows.create").props), { url: "about:blank", focused: false });
+});
+
+// ---- input.debug ----
+test("input.debug without a target does not call tabs.get(-1) and reports the session's own tab", async () => {
+ const h = worker();
+ h.chrome.tabs.get = async (id) => {
+ if (!Number.isInteger(id) || id < 0) throw new Error("Value must be at least 0.");
+ return id === 2 ? { id: 2, windowId: 1, url: "https://fixture.test/" } : Promise.reject(new Error("missing"));
+ };
+ const none = await h.w.dispatch("input.debug", {});
+ assert.equal(none.resolvedTab, null);
+ h.w.resolveOwnedAutomationTarget = async () => ({ id: 2 });
+ const owned = await h.w.dispatch("input.debug", { sessionKey: "s" });
+ assert.equal(owned.resolvedTab.id, 2);
+});
+
+// ---- upload targets ----
+function uploadWorker({ visibility = "visible", chooserMode = "selectMultiple", opensChooser = true } = {}) {
+ const h = worker({ visibility: [visibility] });
+ 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: () => [] };
+ const label = { tagName: "LABEL", control: fileInput, isConnected: true, querySelectorAll: () => [] };
+ const dropzone = { tagName: "DIV", isConnected: true, querySelectorAll: () => [fileInput] };
+ const frameDoc = { querySelector: (s) => (s === "#frame-file" ? frameInput : null), querySelectorAll: () => [] };
+ const doc = {
+ querySelector: (s) => ({ "#file": fileInput, "#button": button, "#label": label, "#drop": dropzone }[s] ?? null),
+ querySelectorAll: (s) => (s === "iframe,frame" ? [{ contentDocument: frameDoc }] : []),
+ };
+ const page = vm.createContext({ document: doc, __PI_CHROME_STATE__: { elements: {} } });
+ page.window = page;
+ const objects = new Map();
+ let nextObject = 1;
+ const base = h.w.cdp;
+ h.w.cdp = async (tabId, method, params = {}) => {
+ if (method === "Runtime.evaluate" && params.objectGroup === "pi-chrome-upload") {
+ h.calls.push({ method, params: {} });
+ try {
+ const el = vm.runInContext(params.expression, page);
+ const objectId = `obj-${nextObject++}`;
+ objects.set(objectId, el);
+ return { result: { objectId } };
+ } catch (error) {
+ return { exceptionDetails: { text: error.message } };
+ }
+ }
+ const result = await base(tabId, method, params);
+ if (method === "Runtime.callFunctionOn" && /getBoundingClientRect/.test(params.functionDeclaration)) {
+ const el = objects.get(params.objectId);
+ const isFile = el.tagName === "INPUT" && el.type === "file";
+ return { result: { value: { tag: el.tagName, isFile, multiple: isFile ? el.multiple : undefined, inFrame: el === frameInput, x: 50, y: 60, width: 80, height: 20 } } };
+ }
+ const clicked = (method === "Input.dispatchMouseEvent" && params.type === "mouseReleased") || (method === "Runtime.callFunctionOn" && params.userGesture === true);
+ if (clicked && opensChooser) setTimeout(() => h.w.handleDebuggerEvent({ tabId: 2 }, "Page.fileChooserOpened", { backendNodeId: 42, mode: chooserMode }), 0);
+ return result;
+ };
+ h.w.waitForFileChooser = ((original) => (tabId) => original(tabId, 200))(h.w.waitForFileChooser);
+ return h;
+}
+const setFiles = (h) => h.calls.filter((c) => c.method === "DOM.setFileInputFiles").map((c) => c.params);
+
+test("upload resolves labels and single-input wrappers to the file input without clicking", async () => {
+ for (const selector of ["#file", "#label", "#drop"]) {
+ const h = uploadWorker();
+ const result = await h.w.dispatch("page.upload", { selector, paths: ["/a.txt", "/b.txt"], background: true });
+ assert.equal(result.mode, "file-input", selector);
+ assert.equal(setFiles(h).length, 1);
+ assert.equal(h.inputs().length, 0, "no click for a real file input");
+ }
+});
+
+test("upload finds file inputs inside same-origin iframes and enforces single-file inputs", async () => {
+ const h = uploadWorker();
+ const result = await h.w.dispatch("page.upload", { selector: "#frame-file", paths: ["/a.txt"], background: true });
+ assert.equal(result.mode, "file-input");
+ assert.equal(result.inFrame, true);
+ await assert.rejects(uploadWorker().w.dispatch("page.upload", { selector: "#frame-file", paths: ["/a.txt", "/b.txt"], background: true }), /accepts one file but 2 paths/);
+});
+
+test("upload buttons open an intercepted file chooser with a trusted click on visible pages", async () => {
+ const h = uploadWorker();
+ const result = await h.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true });
+ assert.equal(result.mode, "file-chooser");
+ assert.equal(result.trigger, "chrome");
+ assert.deepEqual(clone(setFiles(h)), [{ backendNodeId: 42, files: ["/a.txt"] }]);
+ assert.deepEqual(h.calls.filter((c) => c.method === "Page.setInterceptFileChooserDialog").map((c) => c.params.enabled), [true, false]);
+ 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" });
+ 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" });
+ 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);
+});
+
+test("chooser failures attach nothing and always disable interception", async () => {
+ const single = uploadWorker({ chooserMode: "selectSingle" });
+ await assert.rejects(single.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt", "/b.txt"], background: true }), /accepts one file/);
+ assert.equal(setFiles(single).length, 0);
+ const none = uploadWorker({ opensChooser: false });
+ await assert.rejects(none.w.dispatch("page.upload", { selector: "#button", paths: ["/a.txt"], background: true }), /no file chooser opened/);
+ assert.deepEqual(none.calls.filter((c) => c.method === "Page.setInterceptFileChooserDialog").map((c) => c.params.enabled), [true, false]);
+ assert.equal(none.calls.at(-1).method, "Runtime.releaseObject");
+});
+
+test("file chooser events attached by targetId route to the right tab", async () => {
+ const h = worker();
+ vm.runInContext('attachedTabs.set(5, { debuggee: { targetId: "T5" } })', h.w);
+ const pending = h.w.waitForFileChooser(5, 1000);
+ h.w.handleDebuggerEvent({ targetId: "T5" }, "Page.fileChooserOpened", { backendNodeId: 7, mode: "selectSingle" });
+ assert.deepEqual(clone(await pending.promise), { backendNodeId: 7, mode: "selectSingle" });
+});
+
+// ---- screenshot pruning ----
+const pruneSource = indexSource.slice(indexSource.indexOf("const SCREENSHOT_NAME_RE"), indexSource.indexOf("// End screenshot pruning."));
+const prune = vm.runInNewContext(`${stripTypeScriptTypes(pruneSource)}; ({ screenshotFilesToPrune, pruneScreenshotDir })`, {
+ readdir: fs.promises.readdir, unlink: fs.promises.unlink, join: path.join, Date,
+});
+const DAY = 24 * 60 * 60 * 1000;
+const stamp = (ms) => new Date(ms).toISOString().replace(/[:.]/g, "-");
+
+test("screenshot pruning keeps the newest 20 captures and anything inside the retention window", () => {
+ const now = Date.parse("2026-09-26T12:00:00.000Z");
+ const names = [];
+ for (let i = 0; i < 25; i++) names.push(`${stamp(now - (30 + i) * DAY)}.png`);
+ const fullPage = stamp(now - 60 * DAY);
+ names.push(`${fullPage}-tile0.png`, `${fullPage}-tile1.png`, `${fullPage}.png.json`);
+ names.push(`${stamp(now - DAY)}.jpeg`, "my-notes.png", "2026-01-01.png", "keep.json");
+ const pruned = prune.screenshotFilesToPrune(names, now);
+ // 25 old singles + 1 full-page capture + 1 recent = 27 captures; the newest 20 survive. The recent
+ // capture is newest, so 19 old singles survive and 6 old singles + the full-page capture go.
+ assert.equal(pruned.length, 6 + 3);
+ for (const suffix of ["-tile0.png", "-tile1.png", ".png.json"]) assert.ok(pruned.includes(`${fullPage}${suffix}`));
+ for (const name of ["my-notes.png", "2026-01-01.png", "keep.json", `${stamp(now - DAY)}.jpeg`]) assert.ok(!pruned.includes(name), name);
+
+ const recent = Array.from({ length: 30 }, (_, i) => `${stamp(now - i * 60_000)}.png`);
+ assert.equal(prune.screenshotFilesToPrune(recent, now).length, 0, "nothing inside the retention window is removed");
+ assert.equal(prune.screenshotFilesToPrune(names, now, 0).length, 0, "retentionDays:0 disables pruning");
+ assert.equal(prune.screenshotFilesToPrune(names, now, 55).length, 3, "a longer window keeps more");
+});
+
+test("pruneScreenshotDir deletes only eligible files and never throws", async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-chrome-shots-"));
+ const now = Date.now();
+ for (let i = 0; i < 22; i++) fs.writeFileSync(path.join(dir, `${stamp(now - (10 + i) * DAY)}.png`), "x");
+ fs.writeFileSync(path.join(dir, "mine.png"), "x");
+ assert.equal(await prune.pruneScreenshotDir(dir), 2);
+ assert.equal(fs.readdirSync(dir).length, 21);
+ assert.ok(fs.existsSync(path.join(dir, "mine.png")));
+ assert.equal(await prune.pruneScreenshotDir(path.join(dir, "missing")), 0);
+ fs.rmSync(dir, { recursive: true, force: true });
+});