aboutsummaryrefslogtreecommitdiff
path: root/test-suite
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 19:07:56 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 19:07:56 -0400
commit238f347a03b2cefb21198bb78a90699e931d8504 (patch)
tree4c7221441428a086e93f2767a9fb332ee97c9995 /test-suite
parentdf1472b8c777d40daa70da23115f4c8f5afb6623 (diff)
Report hidden-tab input, append targeted typing, smarter uploads, prune screenshots; release 0.15.54
- Check page visibility before trusted input: Chrome drops input to hidden pages (inactive tabs, covered/minimized windows, macOS window tabs behind full screen). Type/key/hover/scroll/tap/drag fail fast with the reason; click/fill use their DOM fallback immediately (or reject with domFallback:false). - Use an inactive tab instead of a new window for the automation target while the user's window is full screen. - chrome_type with uid/selector moves the caret to the end (moveToEndOfDocument) after its focus click. - chrome_upload_file: labels/wrappers resolve to their file input, same-origin iframe search, and intercepted file chooser for upload buttons (trusted click when visible, user-gesture click when hidden); reject multiple paths for single-file inputs; no duplicate change event. - Prune default-folder chrome_screenshot captures (7 days, newest 20 kept, retentionDays override). - input.debug without a target no longer calls tabs.get(-1). - New hidden-input unit suite.
Diffstat (limited to 'test-suite')
-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
4 files changed, 343 insertions, 9 deletions
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");