aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 19:24:12 -0400
committerGitHub <noreply@github.com>2026-09-26 19:24:12 -0400
commitb8dd042d899df98898a299cdb1e030f19983b835 (patch)
tree4c7221441428a086e93f2767a9fb332ee97c9995
parentdf1472b8c777d40daa70da23115f4c8f5afb6623 (diff)
parent238f347a03b2cefb21198bb78a90699e931d8504 (diff)
Merge pull request #21 from tianrendong/fix/hidden-input-uploads-screenshots
Report hidden-tab input, append targeted typing, smarter uploads, prune screenshots; release 0.15.54
-rw-r--r--CHANGELOG.md11
-rw-r--r--README.md2
-rw-r--r--docs/FAQ.md2
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json2
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js274
-rw-r--r--extensions/chrome-profile-bridge/index.ts83
-rw-r--r--package-lock.json4
-rw-r--r--package.json4
-rw-r--r--test-suite/unit/background-policy.test.mjs3
-rw-r--r--test-suite/unit/hidden-input.test.mjs279
-rw-r--r--test-suite/unit/input-reliability.test.mjs45
-rw-r--r--test-suite/unit/type-evidence.test.mjs25
12 files changed, 680 insertions, 54 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2ff5ea..d0f7ae3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
All notable user-facing changes to `pi-chrome`.
+## 0.15.54 — 2026-09-26
+
+- **Hidden tabs report instead of silently dropping input.** Chrome ignores trusted input to hidden pages. A page is hidden when it is an inactive tab, its window is minimized, its window is fully covered by another window, or (on macOS) its window became a hidden window tab behind a full-screen window. In a hidden page, mouse presses and keys were dropped while the tool reported success, and mouse moves could hang until a timeout (the source of `Detached while handling command` in background runs). Input tools now check page visibility first. `chrome_type`, `chrome_key`, `chrome_hover`, `chrome_scroll`, `chrome_tap` and `chrome_drag` fail fast with the reason and the options (`background:false`, or `/chrome background off`). `chrome_click` and `chrome_fill` go straight to their existing DOM fallback and report the reason, or reject with `domFallback:false`. Hard background mode is unchanged: nothing is activated or focused.
+- **No hidden automation windows in full screen.** While your Chrome window is full screen, a new automation window would become a hidden macOS window tab or land on another Space. The automation target is now an inactive tab in your window instead.
+- **Targeted `chrome_type` appends.** With a uid/selector, `chrome_type` clicked a random point inside the field, which left the caret mid-text and spliced input into existing content. After the focus click it now moves the caret to the end with Chrome's `moveToEndOfDocument` editing command. Untargeted typing still uses the current caret; `replace:true` is unchanged.
+- **Upload buttons, labels, wrappers, and iframes.** `chrome_upload_file` accepts a label for a file input, or a wrapper/dropzone containing exactly one, and searches same-origin iframes for selectors. For upload buttons that open the native picker (no persistent `<input type=file>`), it intercepts the file chooser (`Page.setInterceptFileChooserDialog`) so no OS dialog appears. The trigger click uses trusted Chrome input on visible pages. On hidden pages it uses `element.click()` with a CDP user gesture, unless `domFallback:false`. It rejects multiple paths for single-file inputs. Adapted from kkunkunya's fork.
+- **No duplicate `change` on upload.** Chrome's `DOM.setFileInputFiles` already fires `input`/`change`, and pi-chrome dispatched a second pair, which could make apps upload twice. It now dispatches only when Chrome did not, and reports `events: native|dispatched`.
+- **Bounded screenshot folder.** Default-path `chrome_screenshot` captures prune `.pi/chrome-screenshots` at capture time. Captures older than 7 days are removed, but the newest 20 are always kept. Only files matching the tool's own naming (including full-page tiles and manifests) are eligible, and explicit `path:` captures are never pruned. `retentionDays` overrides the window; `0` disables pruning. Adapted from ardhiqii's fork.
+- **`input.debug` without a target.** It no longer fails with `Value must be at least 0`; it describes this session's automation tab if one exists.
+- **Validation.** New `hidden-input` unit suite. Live-checked on Chrome/macOS: hidden-tab errors and DOM fallbacks, caret-end typing in inputs and contenteditables, all upload shapes in visible and hidden tabs, single `change` events, and pruning through a real Pi session.
+
## 0.15.53 — 2026-09-26
Changes adapted from community forks (ardhiqii, kkunkunya, nihar-oracle, steimerbyte).
diff --git a/README.md b/README.md
index 1dfffa7..ba81f0b 100644
--- a/README.md
+++ b/README.md
@@ -85,7 +85,7 @@ Run `/chrome revoke` when finished. Use `/chrome authorize` again whenever you w
This is browser automation, not full OS control. Native Chrome/OS dialogs, password-manager prompts, passkeys/security keys/biometrics, CAPTCHA challenges, cross-origin iframe DOM access, rich multitouch/stylus gestures, and arbitrary desktop apps are outside its reliable tool surface. Some workflows need human assistance.
-If page inspection or evaluation is blocked, use screenshots and coordinate input where possible. Background pages can throttle rendering or reject focus-gated actions. See the [FAQ](./docs/FAQ.md) for details.
+If page inspection or evaluation is blocked, use screenshots and coordinate input where possible. Background pages can throttle rendering or reject focus-gated actions. Chrome ignores real input to hidden pages (inactive tabs, covered or minimized windows, windows behind a macOS full-screen window); input tools report this instead of pretending to succeed. See the [FAQ](./docs/FAQ.md) for details.
## Commands
diff --git a/docs/FAQ.md b/docs/FAQ.md
index d717694..bf46a51 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -75,7 +75,7 @@ If the page did not change, take a fresh snapshot or screenshot and check for ov
## How do I attach a file to a React file input?
-`chrome_upload_file` — uses Chrome DevTools file-input control and fires `input` + `change` events. It does **not** open the native file picker. Works with React/Vue/Angular controlled inputs.
+`chrome_upload_file` — uses Chrome DevTools file-input control; the page gets one `input` + `change`. Target the file input, a label or wrapper containing one, or an upload button: for buttons, the native file chooser is intercepted, so no OS dialog appears. Selectors also search same-origin iframes. Works with React/Vue/Angular controlled inputs.
## Can it record videos?
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json
index 952c9cc..aed24ef 100644
--- a/extensions/chrome-profile-bridge/browser-extension/manifest.json
+++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Pi Chrome Connector",
- "version": "0.15.53",
+ "version": "0.15.54",
"description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
"permissions": [
"tabs",
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index 4bfce34..ead8ae0 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -174,6 +174,17 @@ function isPiChromeOwnedTarget(tabId, sessionKey) {
return false;
}
+// A new window created while the user's Chrome window is full screen does not become a visible
+// standalone window: macOS either merges it into the full-screen window as a hidden native window
+// tab ("Prefer tabs: in full screen") or places it on another Space. Its page is hidden, so trusted
+// input cannot reach it. Use a grouped background tab in the user's window instead, which is at least
+// visible in the tab strip and does not add a macOS tab bar to the user's full-screen window.
+async function userWindowIsFullscreen() {
+ if (typeof chrome.windows?.getLastFocused !== "function") return false;
+ const win = await chrome.windows.getLastFocused({ windowTypes: ["normal"] }).catch(() => null);
+ return win?.state === "fullscreen";
+}
+
// Create a fresh automation target for `sessionKey`. If this session already has a tab group,
// create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome
// groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a
@@ -187,7 +198,7 @@ async function createAutomationTarget(sessionKey, groupTitle) {
await persistAutomationTargets();
return tab;
}
- if (chrome.windows && typeof chrome.windows.create === "function") {
+ if (chrome.windows && typeof chrome.windows.create === "function" && !(await userWindowIsFullscreen())) {
try {
const win = await chrome.windows.create({ url: "about:blank", focused: false });
const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
@@ -391,7 +402,8 @@ async function attachDebugger(tabId) {
}
async function describeInputTarget(tabId) {
- const tab = await chrome.tabs.get(Number(tabId)).catch(() => null);
+ const id = Number(tabId);
+ const tab = Number.isInteger(id) && id >= 0 ? await chrome.tabs.get(id).catch(() => null) : null;
const active = (await chrome.tabs.query({ active: true, lastFocusedWindow: true }).catch(() => []))[0] || null;
let targets = [];
try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
@@ -408,7 +420,13 @@ function targetMetaSuffix(meta) {
}
async function inputDebug(params) {
- const requested = params?.targetId ? await describeInputTarget(Number(params.targetId)) : await describeInputTarget(-1);
+ // No explicit target: describe this session's automation tab if it exists, without creating one.
+ let tabId = params?.targetId !== undefined ? Number(params.targetId) : null;
+ if (tabId === null) {
+ const owned = await resolveOwnedAutomationTarget(sessionKeyOf(params || {})).catch(() => null);
+ tabId = owned?.id ?? null;
+ }
+ const requested = await describeInputTarget(tabId);
return {
extensionVersion: chrome.runtime.getManifest().version,
extensionId: chrome.runtime.id,
@@ -417,6 +435,57 @@ async function inputDebug(params) {
};
}
+// Chrome drops trusted CDP input to hidden pages: mouse presses and keys are silently ignored and
+// mouse moves can hang waiting for a frame. A page is hidden when its tab is inactive, its window is
+// minimized, or (macOS) its window became a native window-tab behind a full-screen window. Check
+// before sending input so the caller gets an actionable error instead of a false success.
+async function pageVisibilityState(tabId) {
+ try {
+ const evaluated = await cdp(tabId, "Runtime.evaluate", { expression: "document.visibilityState", returnByValue: true });
+ const value = evaluated?.result?.value;
+ return typeof value === "string" ? value : null;
+ } catch {
+ return null; // Unknown: do not block input on a failed probe.
+ }
+}
+
+function hiddenTabReason(tab, win) {
+ if (win?.state === "minimized") return "its window is minimized";
+ if (tab && tab.active === false) return "it is an inactive tab in its window";
+ if (win?.state === "fullscreen") return "its window is behind a full-screen window (on macOS, new windows can become hidden window tabs)";
+ return "its window is covered by another window or is on another Space (Chrome treats fully covered windows as hidden)";
+}
+
+async function assertTabVisibleForInput(tab, params, label) {
+ let state = await pageVisibilityState(tab.id);
+ if (state !== "hidden") return;
+ if (foregroundRequested(params)) {
+ // bringToFront just ran; give Chrome a moment to report the page visible.
+ for (let i = 0; i < 6 && state === "hidden"; i++) {
+ await sleep(100);
+ state = await pageVisibilityState(tab.id);
+ }
+ if (state !== "hidden") return;
+ }
+ const fresh = await chrome.tabs.get(tab.id).catch(() => tab);
+ const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(fresh?.windowId ?? tab.windowId).catch(() => null) : null;
+ const error = new Error(
+ `${label}: tab ${tab.id} is hidden because ${hiddenTabReason(fresh, win)}. Chrome ignores trusted input to hidden pages, so the events would be dropped. ` +
+ `Pass background:false for this call (or run /chrome background off) to bring the tab forward. chrome_click and chrome_fill can instead use their DOM fallback (untrusted page events).`,
+ );
+ error.hiddenTab = true;
+ error.windowState = win?.state;
+ throw error;
+}
+
+// Put the caret at the end of the focused field with Chrome's editing command (platform-neutral,
+// works for inputs, textareas, and contenteditables). Used after chrome_type clicks a field to focus
+// it, so the random click point never splices text into the middle of existing content.
+async function moveCaretToEnd(tabId) {
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "End", code: "End", windowsVirtualKeyCode: 35, commands: ["moveToEndOfDocument"] });
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "End", code: "End", windowsVirtualKeyCode: 35 });
+}
+
async function detachDebugger(tabId) {
const entry = attachedTabs.get(tabId);
if (!entry) return;
@@ -825,6 +894,7 @@ async function chromeInputClick(params) {
await bringToFront(tab, params);
try {
await attachDebugger(tab.id);
+ await assertTabVisibleForInput(tab, params, "chrome.click");
const resolved = await resolveTargetInTab(tab.id, params);
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
await cdpMoveTo(tab.id, point.x, point.y);
@@ -861,6 +931,7 @@ async function chromeInputHover(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
await attachDebugger(tab.id);
+ await assertTabVisibleForInput(tab, params, "chrome.hover");
const resolved = await resolveTargetInTab(tab.id, params);
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
await cdpMoveTo(tab.id, point.x, point.y);
@@ -872,6 +943,7 @@ async function chromeInputKey(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
await attachDebugger(tab.id);
+ await assertTabVisibleForInput(tab, params, "chrome.key");
const key = String(params.key || "");
if (!key) throw new Error("chrome.key: missing key");
const mods = params.modifiers || {};
@@ -954,6 +1026,7 @@ async function chromeInputType(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
await attachDebugger(tab.id);
+ await assertTabVisibleForInput(tab, params, "chrome.type");
if (params.selector || params.uid) {
// Focus target by clicking it first.
const resolved = await resolveTargetInTab(tab.id, params);
@@ -963,6 +1036,9 @@ async function chromeInputType(params) {
await sleep(rng(45, 110));
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
await sleep(rng(50, 120));
+ // The click lands at a random point inside the field, which leaves the caret mid-text. Move it to
+ // the end so a targeted chrome_type appends; replace=true selects everything below anyway.
+ if (params.replace !== true) await moveCaretToEnd(tab.id);
}
const text = String(params.text || "");
// chrome_type inserts at the caret. Read the field before and after so a splice into existing
@@ -1144,6 +1220,7 @@ async function chromeInputFill(params) {
await bringToFront(tab, params);
try {
await attachDebugger(tab.id);
+ await assertTabVisibleForInput(tab, params, "chrome.fill");
if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
const resolved = await resolveTargetInTab(tab.id, params);
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
@@ -1174,6 +1251,7 @@ async function chromeInputScroll(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
await attachDebugger(tab.id);
+ await assertTabVisibleForInput(tab, params, "chrome.scroll");
const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y;
@@ -1224,6 +1302,7 @@ async function chromeInputTap(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
await attachDebugger(tab.id);
+ await assertTabVisibleForInput(tab, params, "chrome.tap");
const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number"))
? await resolveTargetInTab(tab.id, params)
: null;
@@ -1240,6 +1319,7 @@ async function chromeInputDrag(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
await attachDebugger(tab.id);
+ await assertTabVisibleForInput(tab, params, "chrome.drag");
const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y };
@@ -1261,6 +1341,100 @@ async function chromeInputDrag(params) {
return { input: "chrome", from: fp, to: tp, steps };
}
+// Pending native file-chooser intercepts, keyed by tabId. chrome_upload_file uses these when the
+// target is an upload trigger (button/dropzone) rather than a persistent <input type=file>:
+// Page.setInterceptFileChooserDialog -> click the trigger -> Page.fileChooserOpened ->
+// DOM.setFileInputFiles(backendNodeId). No OS dialog is shown.
+const pendingFileChoosers = new Map();
+const FILE_CHOOSER_TIMEOUT_MS = 8_000;
+
+function fileChooserTabIdForSource(source) {
+ if (typeof source?.tabId === "number") return source.tabId;
+ if (source?.targetId) {
+ for (const [tabId, entry] of attachedTabs) if (entry?.debuggee?.targetId === source.targetId) return tabId;
+ }
+ return undefined;
+}
+
+function handleDebuggerEvent(source, method, params) {
+ if (method !== "Page.fileChooserOpened") return;
+ const tabId = fileChooserTabIdForSource(source);
+ const entry = tabId === undefined ? undefined : pendingFileChoosers.get(tabId);
+ if (!entry) return;
+ pendingFileChoosers.delete(tabId);
+ entry.resolve({ backendNodeId: params?.backendNodeId, mode: params?.mode });
+}
+
+if (chrome.debugger && chrome.debugger.onEvent) {
+ chrome.debugger.onEvent.addListener(handleDebuggerEvent);
+}
+
+function waitForFileChooser(tabId, timeoutMs) {
+ let entry;
+ const promise = new Promise((resolve, reject) => {
+ entry = { resolve, reject };
+ entry.timer = setTimeout(() => {
+ if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId);
+ reject(new Error("chrome.upload: no file chooser opened after clicking the target. Pass the <input type=file> itself, or an element that opens the file picker when clicked."));
+ }, timeoutMs);
+ });
+ pendingFileChoosers.set(tabId, entry);
+ return { promise: promise.finally(() => clearTimeout(entry.timer)), cancel: () => { if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId); clearTimeout(entry.timer); } };
+}
+
+// Resolve the upload target in the page. Selectors also search same-origin iframes (editors often
+// host their file input in one). A label for a file input, or a wrapper/dropzone containing exactly
+// one file input, resolves to that input so no click is needed.
+function uploadTargetExpression(selector, uid) {
+ return `(() => {
+ const selector = ${JSON.stringify(selector ?? null)};
+ const uid = ${JSON.stringify(uid ?? null)};
+ const state = window.__PI_CHROME_STATE__;
+ const findIn = (doc, depth) => {
+ let found = null;
+ try { found = doc.querySelector(selector); } catch (error) { throw new Error("Invalid selector: " + selector); }
+ if (found || depth >= 3) return found;
+ for (const frame of doc.querySelectorAll("iframe,frame")) {
+ let inner = null;
+ try { inner = frame.contentDocument; } catch {}
+ if (!inner) continue;
+ found = findIn(inner, depth + 1);
+ if (found) return found;
+ }
+ return null;
+ };
+ let el = uid ? state?.elements?.[uid] : (selector ? findIn(document, 0) : null);
+ if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot");
+ if (!el) throw new Error("chrome.upload: target not found: " + (uid || selector));
+ const isFile = (node) => node && node.tagName === "INPUT" && String(node.type).toLowerCase() === "file";
+ if (!isFile(el)) {
+ const control = el.tagName === "LABEL" && isFile(el.control) ? el.control : null;
+ const nested = control ? null : Array.from(el.querySelectorAll ? el.querySelectorAll("input[type=file]") : []);
+ if (control) el = control;
+ else if (nested && nested.length === 1) el = nested[0];
+ }
+ return el;
+ })()`;
+}
+
+const UPLOAD_TARGET_INFO_FN = `function() {
+ const isFile = this.tagName === "INPUT" && String(this.type).toLowerCase() === "file";
+ if (!isFile) this.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
+ const r = this.getBoundingClientRect();
+ let x = r.left + r.width / 2, y = r.top + r.height / 2;
+ let win = this.ownerDocument.defaultView;
+ // Convert iframe-relative coordinates to top-level viewport coordinates (same-origin frames only).
+ while (win && win.frameElement) {
+ const frame = win.frameElement;
+ const fr = frame.getBoundingClientRect();
+ const cs = frame.ownerDocument.defaultView.getComputedStyle(frame);
+ x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
+ y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
+ win = win.parent === win ? null : win.parent;
+ }
+ return { tag: this.tagName, isFile, multiple: isFile ? this.multiple === true : undefined, inFrame: this.ownerDocument !== document, x, y, width: r.width, height: r.height };
+}`;
+
async function chromeInputUpload(params) {
const tab = await getTabByParams(params);
await bringToFront(tab, params);
@@ -1268,36 +1442,80 @@ async function chromeInputUpload(params) {
if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required");
const paths = Array.isArray(params.paths) ? params.paths.map(String) : [];
if (!paths.length) throw new Error("chrome.upload: no file paths provided");
- const expression = `(() => {
- const selector = ${JSON.stringify(params.selector ?? null)};
- const uid = ${JSON.stringify(params.uid ?? null)};
- const state = window.__PI_CHROME_STATE__;
- const el = uid ? state?.elements?.[uid] : (selector ? document.querySelector(selector) : null);
- if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot");
- if (!el || el.tagName !== "INPUT" || el.type !== "file") throw new Error("Target must be <input type=file>");
- el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
- return el;
- })()`;
- const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression, objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
- if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve file input");
+ const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression: uploadTargetExpression(params.selector, params.uid), objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
+ if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve upload target");
const objectId = evaluated.result?.objectId;
- if (!objectId) throw new Error("Could not resolve file input object");
+ if (!objectId) throw new Error("Could not resolve upload target object");
try {
- await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
- // Some DOM agents return nodeId:0 (or reject conversion) for a valid remote
- // element. CDP accepts that same objectId directly, before any file mutation.
- const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null);
- const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId };
- await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths });
- await cdp(tab.id, "Runtime.callFunctionOn", {
- objectId,
- functionDeclaration: `function() { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return this.files ? this.files.length : 0; }`,
- returnByValue: true,
- }).catch(() => undefined);
+ const infoResult = await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: UPLOAD_TARGET_INFO_FN, returnByValue: true });
+ const info = infoResult?.result?.value || {};
+ if (info.isFile) {
+ if (paths.length > 1 && info.multiple === false) throw new Error(`chrome.upload: the file input accepts one file but ${paths.length} paths were given`);
+ await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
+ // Some DOM agents return nodeId:0 (or reject conversion) for a valid remote
+ // element. CDP accepts that same objectId directly, before any file mutation.
+ const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null);
+ const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId };
+ // Chrome's setFileInputFiles normally fires input/change itself. Watch for that and only
+ // dispatch our own events when it did not, so apps never see a duplicate change (double upload).
+ await cdp(tab.id, "Runtime.callFunctionOn", {
+ objectId,
+ functionDeclaration: `function() { const el = this; el.__piChromeChangeSeen = false; el.addEventListener("change", () => { el.__piChromeChangeSeen = true; }, { once: true, capture: true }); }`,
+ }).catch(() => undefined);
+ await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths });
+ const notified = await cdp(tab.id, "Runtime.callFunctionOn", {
+ objectId,
+ functionDeclaration: `function() { const seen = this.__piChromeChangeSeen === true; delete this.__piChromeChangeSeen; if (seen) return "native"; this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return "dispatched"; }`,
+ returnByValue: true,
+ }).catch(() => null);
+ const events = notified?.result?.value;
+ return { input: "chrome", mode: "file-input", events: typeof events === "string" ? events : undefined, inFrame: info.inFrame || undefined, uploaded: paths.map((path) => ({ path })) };
+ }
+ return await uploadViaFileChooser(tab, params, paths, objectId, info);
} finally {
await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
}
- return { input: "chrome", uploaded: paths.map((path) => ({ path })) };
+}
+
+// The target opens the native picker when clicked. Intercept the chooser so no OS dialog appears,
+// then attach the files to the input Chrome reports. The click is trusted Chrome input when the page
+// is visible; a hidden page drops trusted input, so there we call element.click() with a CDP user
+// gesture instead (an untrusted event, like chrome_click's DOM fallback) unless domFallback:false.
+async function uploadViaFileChooser(tab, params, paths, objectId, info) {
+ const hidden = (await pageVisibilityState(tab.id)) === "hidden";
+ if (hidden && params.domFallback === false) await assertTabVisibleForInput(tab, params, "chrome.upload");
+ await cdp(tab.id, "Page.enable", {});
+ await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: true });
+ const chooser = waitForFileChooser(tab.id, FILE_CHOOSER_TIMEOUT_MS);
+ let trigger;
+ try {
+ if (!hidden && typeof info.x === "number" && typeof info.y === "number" && info.width > 0 && info.height > 0) {
+ trigger = "chrome";
+ await cdpMoveTo(tab.id, info.x, info.y);
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: info.x, y: info.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
+ await sleep(rng(40, 110));
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: info.x, y: info.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
+ } else {
+ trigger = "activation-click";
+ await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: "function() { this.click(); }", userGesture: true, awaitPromise: false });
+ }
+ const opened = await chooser.promise;
+ if (typeof opened.backendNodeId !== "number") throw new Error("chrome.upload: file chooser opened without an input node");
+ if (paths.length > 1 && opened.mode === "selectSingle") throw new Error(`chrome.upload: the file chooser accepts one file but ${paths.length} paths were given`);
+ await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
+ await cdp(tab.id, "DOM.setFileInputFiles", { backendNodeId: opened.backendNodeId, files: paths });
+ return {
+ input: "chrome",
+ mode: "file-chooser",
+ trigger,
+ reason: trigger === "activation-click" ? "target tab is hidden; Chrome drops trusted input to hidden pages" : undefined,
+ inFrame: info.inFrame || undefined,
+ uploaded: paths.map((path) => ({ path })),
+ };
+ } finally {
+ chooser.cancel();
+ await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => undefined);
+ }
}
// ===============================================================
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 46be3d9..e9e21e9 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -1,7 +1,7 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { existsSync, readFileSync, statSync } from "node:fs";
-import { mkdir, writeFile } from "node:fs/promises";
+import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { dirname, join, resolve } from "node:path";
@@ -236,6 +236,58 @@ function formatCdpResult(method: string, value: unknown): ToolTextResult {
return { content: [{ type: "text", text: truncateText(text) }], details: { value } };
}
+// chrome_screenshot writes a timestamped file per capture (plus -tileN files and a .json manifest for
+// full-page captures) into .pi/chrome-screenshots. Prune that folder at capture time so it stays
+// bounded. Only files matching the tool's own naming are eligible; the newest `keep` captures are always
+// kept, and nothing inside the retention window is removed. Best-effort: errors never fail a capture.
+const SCREENSHOT_NAME_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(?:-tile\d+)?\.(?:png|jpeg)(?:\.json)?$/;
+const SCREENSHOT_KEEP_NEWEST = 20;
+const SCREENSHOT_RETENTION_DAYS = 7;
+
+function screenshotCaptureTime(stamp: string): number {
+ const iso = stamp.replace(/^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "$1:$2:$3.$4Z");
+ return Date.parse(iso);
+}
+
+function screenshotFilesToPrune(names: string[], now: number, retentionDays = SCREENSHOT_RETENTION_DAYS, keep = SCREENSHOT_KEEP_NEWEST): string[] {
+ if (!(retentionDays > 0)) return [];
+ const captures = new Map<string, string[]>();
+ for (const name of names) {
+ const match = SCREENSHOT_NAME_RE.exec(name);
+ if (!match) continue;
+ const files = captures.get(match[1]) ?? [];
+ files.push(name);
+ captures.set(match[1], files);
+ }
+ const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
+ const newestFirst = [...captures.keys()].sort().reverse();
+ const prune: string[] = [];
+ for (const stamp of newestFirst.slice(keep)) {
+ const takenAt = screenshotCaptureTime(stamp);
+ if (Number.isFinite(takenAt) && takenAt < cutoff) prune.push(...(captures.get(stamp) ?? []));
+ }
+ return prune;
+}
+
+async function pruneScreenshotDir(dir: string, retentionDays?: number): Promise<number> {
+ try {
+ const names = await readdir(dir);
+ let removed = 0;
+ for (const name of screenshotFilesToPrune(names, Date.now(), retentionDays)) {
+ try {
+ await unlink(join(dir, name));
+ removed++;
+ } catch {
+ // Locked or already gone: keep going.
+ }
+ }
+ return removed;
+ } catch {
+ return 0;
+ }
+}
+// End screenshot pruning.
+
function formatChromeInspect(inspect: any): string {
if (!inspect || typeof inspect !== "object") return safeJson(inspect);
const t = inspect.target || {};
@@ -1066,7 +1118,8 @@ Capability model (important):
- Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
- Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly. If the action started a navigation, the snapshot waits (up to 5s) for the new page.
-- \`chrome_type\` inserts at the caret and never replaces; check its before/after report. Use \`chrome_fill\` to replace a field.
+- \`chrome_type\` never replaces: with a uid/selector it appends at the end of the field; without one it types at the current caret. Check its before/after report. Use \`chrome_fill\` to replace a field.
+- Chrome ignores real input to hidden pages (inactive tabs, minimized windows, windows behind a macOS full-screen window). Input tools report this instead of silently doing nothing; retry with background:false only when bringing the tab forward is acceptable.
- \`chrome_cdp\` runs any raw CDP method on a tab (emulation, cookies, PDF, accessibility tree, etc.) when no dedicated chrome_* tool fits; \`chrome_cdp_targets\` diagnoses debugger/overlay conflicts.
Usage rules:
@@ -1630,7 +1683,7 @@ Usage rules:
selector: Type.Optional(Type.String({ description: "CSS selector to click. Prefer uid from chrome_snapshot when available." })),
x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })),
y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })),
- domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
+ domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked (extension overlay, debugger failure, or a hidden tab that ignores trusted input)." })),
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the click." })),
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
targetId: Type.Optional(Type.String()),
@@ -1654,8 +1707,8 @@ Usage rules:
name: "chrome_type",
label: "Chrome Type",
description:
- "Focus an optional snapshot uid or CSS selector, then type at the caret using Chrome's real input. It does NOT replace existing text: in a non-empty field the text is inserted wherever the caret lands after the focus click. Use chrome_fill (or replace=true) to replace a field's value. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. The result reports the field value before/after and insertedAt (empty|caret-end|caret-middle|replaced-selection|replaced-all), with a warning when text was spliced into existing content. Pass includeSnapshot=true to verify after typing; if the action starts a navigation, the snapshot waits (up to 5s) for it to load.",
- promptSnippet: "Type text at the caret in Chrome (does not replace existing text; use chrome_fill or replace=true).",
+ "Type using Chrome's real input. With a snapshot uid or CSS selector, chrome_type clicks the field and moves the caret to the end, so text is appended to existing content; without one it types at the current caret of the focused element. It does NOT replace existing text. Use chrome_fill (or replace=true) to replace a field's value. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. The result reports the field value before/after and insertedAt (empty|caret-end|caret-middle|replaced-selection|replaced-all), with a warning when text was spliced into existing content. Pass includeSnapshot=true to verify after typing; if the action starts a navigation, the snapshot waits (up to 5s) for it to load.",
+ promptSnippet: "Type text in Chrome; a uid/selector target appends at the end (does not replace existing text; use chrome_fill or replace=true).",
parameters: Type.Object({
text: Type.String(),
uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
@@ -1695,7 +1748,7 @@ Usage rules:
selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
perCharacter: Type.Optional(Type.Boolean({ default: false, description: "Send individual key events even in contenteditables. Default: one native text insertion for contenteditables; key events for other fields." })),
submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
- domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
+ domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked (extension overlay, debugger failure, or a hidden tab that ignores trusted input)." })),
includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })),
maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
targetId: Type.Optional(Type.String()),
@@ -1845,6 +1898,7 @@ Usage rules:
format: Type.Optional(StringEnum(imageFormatValues)),
quality: Type.Optional(Type.Number({ minimum: 0, maximum: 100, description: "JPEG quality 0-100." })),
fullPage: Type.Optional(Type.Boolean({ description: "Capture full-page tiles plus a JSON manifest. Temporarily scrolls the target page; does not activate background tabs." })),
+ retentionDays: Type.Optional(Type.Number({ minimum: 0, description: "Default-folder captures older than this many days are pruned (the newest 20 are always kept). Default 7; 0 disables pruning. Explicit paths are never pruned." })),
targetId: Type.Optional(Type.String()),
urlIncludes: Type.Optional(Type.String()),
titleIncludes: Type.Optional(Type.String()),
@@ -1855,8 +1909,10 @@ Usage rules:
async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<ToolTextResult> {
const format = params.format ?? "png";
const cwd = workspaceCwd(ctx);
- const defaultPath = join(cwd, ".pi", "chrome-screenshots", `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`);
+ const screenshotDir = join(cwd, ".pi", "chrome-screenshots");
+ const defaultPath = join(screenshotDir, `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`);
const outputPath = params.path ? resolve(cwd, params.path) : defaultPath;
+ const prune = () => (params.path ? Promise.resolve(0) : pruneScreenshotDir(screenshotDir, params.retentionDays));
const result = (await authorizedBridgeSend("page.screenshot", params, params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS, signal)) as {
dataUrl?: string;
method?: string;
@@ -1880,6 +1936,7 @@ Usage rules:
manifest.push({ path: tilePath, y: tile.y });
}
await writeFile(outputPath + ".json", JSON.stringify({ width, height, viewportHeight, dpr, tiles: manifest }, null, 2));
+ await prune();
return {
content: [{ type: "text", text: `Saved ${result.tiles.length} full-page tile(s) for ${width}×${height}px page. Manifest: ${outputPath}.json` }],
details: { manifest: outputPath + ".json", tiles: manifest, dimensions: result.dimensions, tab: result.tab, method: result.method } as unknown as Record<string, unknown>,
@@ -1888,6 +1945,7 @@ Usage rules:
if (!result.dataUrl) throw new Error("Screenshot returned no dataUrl");
const base64 = result.dataUrl.replace(/^data:image\/(?:png|jpeg);base64,/, "");
await writeFile(outputPath, Buffer.from(base64, "base64"));
+ await prune();
return { content: [{ type: "text", text: `Saved Chrome screenshot to ${outputPath}` }], details: { path: outputPath, format, tab: result.tab, method: result.method } };
},
});
@@ -1987,12 +2045,13 @@ Usage rules:
pi.registerTool({
name: "chrome_upload_file",
label: "Chrome Upload File",
- description: "Attach local files to an <input type=file> element using Chrome DevTools file-input control. Does NOT open the native file picker; works with React/Vue/Angular controlled inputs.",
+ description: "Attach local files using Chrome DevTools file-input control without opening a native file picker. Target the <input type=file>, a label/wrapper containing one, or an upload button that opens the picker (the chooser is intercepted). Selectors also search same-origin iframes. Works with React/Vue/Angular controlled inputs.",
promptSnippet: "Attach local files to a Chrome <input type=file> without opening the native file picker.",
parameters: Type.Object({
uid: Type.Optional(Type.String()),
selector: Type.Optional(Type.String()),
paths: Type.Array(Type.String(), { description: "Local absolute file paths to upload." }),
+ domFallback: Type.Optional(Type.Boolean({ description: "Upload buttons need a click to open the (intercepted) file chooser. If true (default) and the tab is hidden, use a page-level element.click() with a user gesture instead of trusted input. Set false to require trusted input." })),
targetId: Type.Optional(Type.String()),
urlIncludes: Type.Optional(Type.String()),
titleIncludes: Type.Optional(Type.String()),
@@ -2001,8 +2060,12 @@ Usage rules:
async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
const cwd = workspaceCwd(ctx);
const paths = params.paths.map((p) => resolve(cwd, p));
- const result = await authorizedBridgeSend("page.upload", { ...params, paths }, DEFAULT_TIMEOUT_MS, signal);
- return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
+ const result = (await authorizedBridgeSend("page.upload", { ...params, paths }, DEFAULT_TIMEOUT_MS, signal)) as { mode?: string; trigger?: string; inFrame?: boolean } | undefined;
+ const via = result?.mode === "file-chooser"
+ ? ` via intercepted file chooser (${result.trigger === "activation-click" ? "page-level click because the tab is hidden" : "trusted click"})`
+ : "";
+ const frame = result?.inFrame ? " inside an iframe" : "";
+ return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}${frame}${via}` }], details: { result: result as Json } };
},
});
diff --git a/package-lock.json b/package-lock.json
index a850062..7693387 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pi-chrome",
- "version": "0.15.53",
+ "version": "0.15.54",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-chrome",
- "version": "0.15.53",
+ "version": "0.15.54",
"license": "MIT",
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
diff --git a/package.json b/package.json
index 3ae9aa3..6767806 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"name": "pi-chrome",
- "version": "0.15.53",
+ "version": "0.15.54",
"scripts": {
- "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs",
+ "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs",
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"
},
diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs
index fe71634..2388d78 100644
--- a/test-suite/unit/background-policy.test.mjs
+++ b/test-suite/unit/background-policy.test.mjs
@@ -50,7 +50,7 @@ function piHarness({ session = "alpha", send } = {}) {
formatChromeInspect: JSON.stringify, summarizeActionResult: () => "", formatIncludedSnapshotText: (_r, text) => text,
describeTypeEvidence: () => [], formatCdpResult: (_method, value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: { value } }),
workspaceCwd: () => ctx.cwd, ...path,
- mkdir: async () => {}, writeFile: async (...args) => writes.push(args),
+ mkdir: async () => {}, writeFile: async (...args) => writes.push(args), pruneScreenshotDir: async () => 0,
};
const registrations = indexSource.slice(indexSource.indexOf("function registerChromeTools(pi:"), indexSource.lastIndexOf("\n}"));
vm.runInNewContext(stripTypeScriptTypes([
@@ -292,6 +292,7 @@ function workerHarness({ withWindows = true } = {}) {
w, chrome, calls, tabs, focusedWindow: () => focusedWindow,
cdpResult: (method) => method === "Page.captureScreenshot" ? { data: "c2NyZWVuc2hvdA==" }
: method === "DOM.requestNode" ? { nodeId: 1 }
+ : method === "Runtime.callFunctionOn" ? { result: { type: "object", value: { tag: "INPUT", isFile: true } } }
: { result: { type: "boolean", value: true, objectId: "file-input" } },
};
return h;
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 });
+});
diff --git a/test-suite/unit/input-reliability.test.mjs b/test-suite/unit/input-reliability.test.mjs
index 1329ed7..4a47e9c 100644
--- a/test-suite/unit/input-reliability.test.mjs
+++ b/test-suite/unit/input-reliability.test.mjs
@@ -17,6 +17,14 @@ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {}
isContentEditable: editable, isConnected: true, textContent: initial,
scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 200, height: 60 }),
contains: (el) => el === element,
+ querySelectorAll: () => [],
+ listeners: [], events: [],
+ addEventListener(type, fn) { this.listeners.push({ type, fn }); },
+ dispatchEvent(event) {
+ this.events.push(event.type);
+ for (const l of this.listeners.splice(0)) if (l.type === event.type) l.fn(event); else this.listeners.push(l);
+ return true;
+ },
};
const selection = {
removeAllRanges() { selectAll = false; },
@@ -33,6 +41,9 @@ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {}
__PI_CHROME_STATE__: { elements: { "el-1": element } },
});
page.window = page;
+ page.document.defaultView = page;
+ page.document.querySelectorAll = () => [];
+ element.ownerDocument = page.document;
const listener = { addListener() {}, removeListener() {} };
const chrome = {
runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener },
@@ -71,8 +82,15 @@ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {}
return { exceptionDetails: { text: error.message, exception: { description: error.message } } };
}
}
+ if (method === "Runtime.callFunctionOn" && params.objectId === "upload-object") {
+ const fn = vm.runInContext(`(${params.functionDeclaration})`, page);
+ return { result: { value: clone(fn.call(element) ?? null) } };
+ }
if (method === "DOM.requestNode") return h.nodeResult;
- if (method === "DOM.setFileInputFiles") files.push(...params.files);
+ if (method === "DOM.setFileInputFiles") {
+ files.push(...params.files);
+ if (h.nativeChange) { element.dispatchEvent({ type: "input" }); element.dispatchEvent({ type: "change" }); }
+ }
if (method === "Input.insertText") element.textContent += params.text;
if (method === "Input.dispatchKeyEvent" && params.type === "keyDown") {
if (params.key === "Delete") {
@@ -88,6 +106,7 @@ function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {}
element.textContent = params.text;
return { input: "dom-fallback" };
};
+ page.Event = class { constructor(type) { this.type = type; } };
const h = {
worker, chrome, page, calls, element, selected, files, tabLookups, failures: new Map(), nodeResult: { nodeId },
call: (action, params = {}) => worker.dispatch(`page.${action}`, { targetId: "2", background: true, ...params }),
@@ -100,6 +119,7 @@ for (const [name, nodeResult] of [["node ID", { nodeId: 7 }], ["zero node ID", {
test(`upload accepts ${name} and releases the remote object`, async () => {
const h = harness({ tag: "INPUT", editable: false });
h.element.type = "file";
+ h.element.multiple = true;
h.nodeResult = nodeResult;
const result = await h.call("upload", { uid: "el-1", paths: ["/tmp/a.txt", "/tmp/b.txt"] });
const target = nodeResult?.nodeId ? { nodeId: 7 } : { objectId: "upload-object" };
@@ -112,6 +132,17 @@ for (const [name, nodeResult] of [["node ID", { nodeId: 7 }], ["zero node ID", {
});
}
+test("upload never duplicates the change event Chrome already fired", async () => {
+ for (const nativeChange of [true, false]) {
+ const h = harness({ tag: "INPUT", editable: false });
+ h.element.type = "file";
+ h.nativeChange = nativeChange;
+ const result = await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] });
+ assert.equal(result.events, nativeChange ? "native" : "dispatched");
+ assert.deepEqual(h.element.events.filter((t) => t === "change"), ["change"], `exactly one change (native=${nativeChange})`);
+ }
+});
+
test("upload falls back on requestNode failure, not on a failed file attachment", async () => {
const h = harness({ tag: "INPUT", editable: false });
h.element.type = "file";
@@ -124,7 +155,7 @@ test("upload falls back on requestNode failure, not on a failed file attachment"
await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /attachment denied/);
assert.equal(h.commands("DOM.setFileInputFiles").length, 1, "no object-ID retry after an uncertain file attachment");
assert.equal(h.commands("Runtime.releaseObject").length, 1, "cleanup still runs on error");
- assert.equal(h.commands("Runtime.callFunctionOn").length, 0, "no notification after failed attachment");
+ assert.equal(h.commands("Runtime.callFunctionOn").filter((c) => /dispatchEvent/.test(c.params.functionDeclaration)).length, 0, "no notification after failed attachment");
});
test("upload releases valid node-path references on failure and tolerates notification/cleanup failure", async () => {
@@ -134,14 +165,16 @@ test("upload releases valid node-path references on failure and tolerates notifi
await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /file inaccessible/);
assert.equal(h.commands("Runtime.releaseObject").length, 1);
h.failures.delete("DOM.setFileInputFiles");
- for (const method of ["DOM.enable", "Runtime.callFunctionOn", "Runtime.releaseObject"]) h.failures.set(method, "optional step failed");
+ for (const method of ["DOM.enable", "Runtime.releaseObject"]) h.failures.set(method, "optional step failed");
const result = await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] });
assert.equal(result.input, "chrome");
});
-test("upload rejects non-file elements, missing targets/paths, and stale UIDs without attaching anything", async () => {
+test("upload rejects non-trigger elements, missing targets/paths, and stale UIDs without attaching anything", async () => {
const h = harness();
- await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /Target must be <input type=file>/);
+ h.worker.waitForFileChooser = () => ({ promise: Promise.reject(new Error("chrome.upload: no file chooser opened after clicking the target.")), cancel() {} });
+ await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /no file chooser opened/);
+ assert.deepEqual(h.commands("Page.setInterceptFileChooserDialog").map((c) => c.params.enabled), [true, false], "intercept is always disabled again");
await assert.rejects(h.call("upload", { paths: ["/tmp/a.txt"] }), /selector or uid required/);
await assert.rejects(h.call("upload", { selector: "#target", paths: [] }), /no file paths/);
h.element.tagName = "INPUT";
@@ -158,7 +191,7 @@ for (const target of [{ selector: "#target" }, { uid: "el-1" }, {}]) {
const h = harness();
const result = await h.call("type", { ...target, text: caption });
assert.deepEqual(h.commands("Input.insertText").map((c) => c.params), [{ text: caption }]);
- assert.equal(h.commands("Input.dispatchKeyEvent").length, 0);
+ assert.equal(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.key !== "End").length, 0);
assert.equal(h.element.textContent, caption);
assert.equal(result.input, "chrome");
assert.equal(result.length, caption.length);
diff --git a/test-suite/unit/type-evidence.test.mjs b/test-suite/unit/type-evidence.test.mjs
index f0a51c1..22393be 100644
--- a/test-suite/unit/type-evidence.test.mjs
+++ b/test-suite/unit/type-evidence.test.mjs
@@ -59,6 +59,7 @@ function harness(element, { platform = "MacIntel", tabs = {} } = {}) {
calls.push({ method, params: clone(params) });
if (method === "Input.dispatchKeyEvent") {
if ((params.commands || []).includes("selectAll")) { element.selectionStart = 0; element.selectionEnd = element.value.length; }
+ else if ((params.commands || []).includes("moveToEndOfDocument") && typeof element.value === "string") { element.selectionStart = element.selectionEnd = element.value.length; }
else if (params.type === "keyDown" && params.key === "Delete") {
if (element.selectionEnd > element.selectionStart) splice("");
} else if (params.type === "keyDown" && params.text && params.key !== "Enter") splice(params.text);
@@ -68,9 +69,29 @@ function harness(element, { platform = "MacIntel", tabs = {} } = {}) {
return { worker, calls, element, listeners, fire: (tabId, info) => { for (const fn of [...listeners]) fn(tabId, info); } };
}
-test("typing into the middle of a prefilled field reports the splice instead of a bare count", async () => {
+test("a targeted chrome_type moves the caret to the end after its focus click, so it appends", async () => {
const h = harness(makeInput({ value: "why do flamingos stand", caret: 7 }));
- const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "cats " });
+ const result = await h.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: " still" });
+ assert.equal(h.element.value, "why do flamingos stand still");
+ assert.equal(result.insertedAt, "caret-end");
+ const moves = h.calls.filter((c) => (c.params.commands || []).includes("moveToEndOfDocument"));
+ assert.equal(moves.length, 1);
+ assert.equal(h.calls.indexOf(moves[0]) > h.calls.findIndex((c) => c.method === "Input.dispatchMouseEvent" && c.params.type === "mouseReleased"), true, "caret moves after the focus click");
+});
+
+test("replace=true does not move the caret first; untargeted typing keeps the user's caret", async () => {
+ const replace = harness(makeInput({ value: "old", caret: 1 }));
+ await replace.worker.dispatch("page.type", { targetId: "2", selector: "#q", text: "new", replace: true });
+ assert.equal(replace.calls.some((c) => (c.params.commands || []).includes("moveToEndOfDocument")), false);
+ const focused = harness(makeInput({ value: "abc", caret: 1 }));
+ await focused.worker.dispatch("page.type", { targetId: "2", text: "X" });
+ assert.equal(focused.calls.some((c) => (c.params.commands || []).includes("moveToEndOfDocument")), false);
+ assert.equal(focused.element.value, "aXbc");
+});
+
+test("typing into the middle of a focused prefilled field reports the splice instead of a bare count", async () => {
+ const h = harness(makeInput({ value: "why do flamingos stand", caret: 7 }));
+ const result = await h.worker.dispatch("page.type", { targetId: "2", text: "cats " });
assert.equal(h.element.value, "why do cats flamingos stand");
assert.equal(result.valueBefore, "why do flamingos stand");
assert.equal(result.valueAfter, "why do cats flamingos stand");