// 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"); });