test-suite/unit/hidden-input.test.mjs (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
// 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 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.
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);
}
});
// ---- 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 });
});
// ---- /chrome doctor visibility report ----
test("automation.status reports whether this session's own tab is visible, without creating one", async () => {
const h = worker({ tab: { id: 2, windowId: 1, active: false, url: "https://fixture.test/" }, win: { id: 1, state: "normal" } });
h.chrome.scripting.executeScript = async () => [{ result: "hidden" }];
const none = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
assert.deepEqual(clone(none), { windowId: null, tabId: null });
assert.equal(h.calls.some((c) => c.method === "tabs.create" || c.method === "windows.create"), false);
vm.runInContext("automationTargets.set('s', { tabId: 2, windowId: undefined })", h.w);
const hidden = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
assert.equal(hidden.visibility, "hidden");
assert.match(hidden.hiddenReason, /inactive tab/);
h.chrome.scripting.executeScript = async () => [{ result: "visible" }];
const visible = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true });
assert.equal(visible.visibility, "visible");
assert.equal(visible.hiddenReason, undefined);
assert.deepEqual(clone(await h.w.dispatch("automation.status", { sessionKey: "s" })), { windowId: null, tabId: 2 }, "plain status is unchanged");
});
|