From ec23b553a245d3d57f428d03002ed77a785d300a Mon Sep 17 00:00:00 2001 From: Tianren Dong Date: Thu, 10 Sep 2026 09:31:19 -0400 Subject: Improve Chrome input reliability and release 0.15.49 --- CHANGELOG.md | 10 + README.md | 6 + .../browser-extension/manifest.json | 2 +- .../browser-extension/service_worker.js | 108 ++++-- extensions/chrome-profile-bridge/index.ts | 8 +- package-lock.json | 4 +- package.json | 4 +- test-suite/README.md | 9 + test-suite/challenges/21-keyboard-modifiers.html | 7 +- test-suite/challenges/44-input-reliability.html | 105 ++++++ test-suite/manifest.json | 46 ++- test-suite/unit/background-policy.test.mjs | 20 + test-suite/unit/input-reliability.test.mjs | 401 +++++++++++++++++++++ 13 files changed, 691 insertions(+), 39 deletions(-) create mode 100644 test-suite/challenges/44-input-reliability.html create mode 100644 test-suite/unit/input-reliability.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a5b8675..c40b76e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable user-facing changes to `pi-chrome`. +## 0.15.49 — 2026-09-10 + +- **Validation scope.** Node regression suites passed. Live browser validation remains incomplete: an input attempt encountered `Input.dispatchMouseEvent: Detached while handling command.`; a subsequent retest was blocked by a disconnected companion. No live-browser pass is claimed for these changes. + +- **Upload node fallback.** When Chrome cannot convert a file input's remote object to a DOM node ID, use that same `objectId` directly. Release the remote object after success or failure and reject stale snapshot UIDs. No native file picker or new permissions. +- **Native rich-editor insertion.** `chrome_type` and `chrome_fill` use one CDP `Input.insertText` for focused contenteditables. Inputs/textareas keep individual key events; `perCharacter:true` preserves that path for rich editors needing `keydown` events. Results report `typing` as `insertText`, `keys`, or `none`. Existing DOM-fallback controls and background policy remain intact. +- **Complete rich-editor fill and single Enter.** Select all requested contenteditable contents before deletion, not only one paragraph. `pressEnter` sends one Enter instead of two; Enter after type/fill stays pinned to the resolved tab. +- **Shift-only typing.** Shift+a, Shift+1, and other US-layout printable chords now carry shifted text. Ctrl/Meta/Alt shortcuts still suppress literal insertion. +- **Input regressions.** Added worker fault-injection tests and challenge 44. Challenge 16 explicitly tests per-character typing; challenge 21 waits for Shift release before grading. + ## 0.15.48 — 2026-09-09 - **Existing background mode is now hard background.** `/chrome background on` (still the default) overrides per-call foreground requests, keeps new tabs inactive, and blocks `chrome_tab activate`. Use the existing `/chrome background off` for foreground/watch mode; no new command or lock state. diff --git a/README.md b/README.md index 094dfe6..a6774bc 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ Second doctor run should show all checks passing. Tool parameters and gotchas are documented inline in Pi. +### Typing into rich editors + +`chrome_type` and `chrome_fill` use one native CDP `Input.insertText` operation for focused contenteditables. This avoids per-character delays for long text and preserves Unicode/newlines. Ordinary inputs and textareas retain individual key events. + +For an editor that needs a `keydown` for every character, pass `perCharacter:true`. Bulk insertion still uses Chrome's input system, but does not emit per-character key events or a clipboard `paste` event. Use `includeSnapshot:true` to verify the result; `chrome_fill` still honors `domFallback:false` when synthetic fallback is unwanted. + --- ## Safety model diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index 38d1b7f..2fd4915 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.48", + "version": "0.15.49", "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 fd6c452..8ba0e2d 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -681,7 +681,13 @@ function cdpKeyInfo(key, shifted) { }; if (SPECIAL[key]) return { key, ...SPECIAL[key] }; if (key.length === 1) { - const ch = key; + // Explicit Shift chords need shifted text as well as a modifier bit. CDP does + // not derive printable text from code/windowsVirtualKeyCode for us. + const SHIFTED = { + "`": "~", "1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", + "-": "_", "=": "+", "[": "{", "]": "}", "\\": "|", ";": ":", "'": "\"", ",": "<", ".": ">", "/": "?", + }; + const ch = shifted ? (/^[a-z]$/.test(key) ? key.toUpperCase() : SHIFTED[key] || key) : key; const layout = usKeyLayoutForChar(ch); return { key: ch, code: layout.code, windowsVirtualKeyCode: layout.keyCode, text: ch }; } @@ -809,13 +815,14 @@ async function chromeInputKey(params) { await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: m.key, code: m.code, windowsVirtualKeyCode: m.vk, modifiers: modBits }); await sleep(rng(6, 18)); } - const info = cdpKeyInfo(key); - // When modifiers are active, browsers usually emit "rawKeyDown" (no text) so chords like Cmd+V don't insert the literal char. - const downType = modBits ? "rawKeyDown" : "keyDown"; + const info = cdpKeyInfo(key, mods.shiftKey); + // Ctrl/Meta/Alt chords must not insert literal text (e.g. Cmd+V). Shift alone + // still types: Shift+a -> A, Shift+1 -> !, and Shift+Enter carries a newline. + const shortcut = !!(mods.ctrlKey || mods.metaKey || mods.altKey); await cdp(tab.id, "Input.dispatchKeyEvent", { - type: downType, key: info.key, code: info.code, + type: shortcut ? "rawKeyDown" : "keyDown", key: info.key, code: info.code, windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode, - text: modBits ? "" : info.text, unmodifiedText: modBits ? "" : info.text, modifiers: modBits, + text: shortcut ? "" : info.text, unmodifiedText: shortcut ? "" : info.text, modifiers: modBits, }); await sleep(rng(25, 90)); await cdp(tab.id, "Input.dispatchKeyEvent", { @@ -829,6 +836,49 @@ async function chromeInputKey(params) { return { input: "chrome", key: info.key, modifiers: mods }; } +// Read the actual focused editor, not a role=textbox lookalike. For fill, select +// the requested editor's entire contents: triple-click only selects a paragraph. +// Selection uses the DOM; deletion and insertion still use Chrome's input layer. +async function contentEditableInTab(tabId, selectAllParams = null) { + const results = await executeScriptTimed({ + target: { tabId, frameIds: [0] }, + world: "MAIN", + func: (selector, uid, selectAll) => { + const active = document.activeElement; + if (selectAll) { + const state = window.__PI_CHROME_STATE__; + const el = uid ? state?.elements?.[uid] : document.querySelector(selector); + if (uid && (!el || !el.isConnected)) throw new Error(`snapshot uid ${uid} is stale; refresh chrome_snapshot`); + if (!el?.isContentEditable) return false; + if (!active?.isContentEditable || !(el === active || el.contains(active) || active.contains(el))) { + throw new Error("chrome.fill: requested contenteditable is not focused"); + } + const selection = window.getSelection(); + if (!selection) throw new Error("Could not select contenteditable contents"); + const range = document.createRange(); + range.selectNodeContents(el); + selection.removeAllRanges(); + selection.addRange(range); + } + return active?.isContentEditable === true; + }, + args: [selectAllParams?.selector ?? null, selectAllParams?.uid ?? null, selectAllParams !== null], + }, `inspect contenteditable in tab ${tabId}`); + return results?.[0]?.result === true; +} + +async function typeTextInTab(tabId, text, perCharacter) { + if (!text) return "none"; + if (!perCharacter && await contentEditableInTab(tabId)) { + // One native edit avoids per-character delays and rich-editor render races. + // Do not retry as keystrokes if insertion fails: it may already have applied. + await cdp(tabId, "Input.insertText", { text }); + return "insertText"; + } + for (const ch of Array.from(text)) await cdpTypeChar(tabId, ch); + return "keys"; +} + async function chromeInputType(params) { const tab = await getTabByParams(params); await bringToFront(tab, params); @@ -844,12 +894,9 @@ async function chromeInputType(params) { await sleep(rng(50, 120)); } const text = String(params.text || ""); - for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch); - if (params.pressEnter) { - await cdpTypeChar(tab.id, "\r").catch(() => undefined); - await chromeInputKey({ ...params, key: "Enter" }); - } - return { input: "chrome", length: text.length }; + const typing = await typeTextInTab(tab.id, text, params.perCharacter); + if (params.pressEnter) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" }); + return { input: "chrome", length: text.length, typing }; } async function domFillFallback(tabId, params, cause) { @@ -908,14 +955,15 @@ async function chromeInputFill(params) { await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" }); await sleep(rng(20, 60)); } + await contentEditableInTab(tab.id, params); // Delete selection. await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); await sleep(rng(20, 60)); const text = String(params.text || ""); - for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch); - if (params.submit) await chromeInputKey({ ...params, key: "Enter" }); - return { input: "chrome", length: text.length }; + const typing = await typeTextInTab(tab.id, text, params.perCharacter); + if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" }); + return { input: "chrome", length: text.length, typing }; } catch (error) { if (params.domFallback === false) throw error; return domFillFallback(tab.id, params, error); @@ -1024,25 +1072,31 @@ async function chromeInputUpload(params) { const selector = ${JSON.stringify(params.selector ?? null)}; const uid = ${JSON.stringify(params.uid ?? null)}; const state = window.__PI_CHROME_STATE__; - const el = uid && state && state.elements ? state.elements[uid] : (selector ? document.querySelector(selector) : null); + 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 "); 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(evaluated.exceptionDetails.text || "Could not resolve file input"); + if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve file input"); const objectId = evaluated.result?.objectId; if (!objectId) throw new Error("Could not resolve file input object"); - await cdp(tab.id, "DOM.enable", {}).catch(() => undefined); - const requested = await cdp(tab.id, "DOM.requestNode", { objectId }); - if (!requested.nodeId) throw new Error("Could not resolve file input node"); - await cdp(tab.id, "DOM.setFileInputFiles", { nodeId: requested.nodeId, 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); - await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined); + 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); + } finally { + await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined); + } return { input: "chrome", uploaded: paths.map((path) => ({ path })) }; } // =============================================================== diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 9546b9c..40a353a 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -1564,12 +1564,13 @@ Usage rules: name: "chrome_type", label: "Chrome Type", description: - "Focus an optional snapshot uid or CSS selector, then type text using Chrome's real keyboard input. Pass includeSnapshot=true to return a fresh snapshot after typing.", + "Focus an optional snapshot uid or CSS selector, then type using Chrome's real input. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. Pass includeSnapshot=true to verify after typing.", promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.", parameters: Type.Object({ text: Type.String(), uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })), selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })), + 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." })), includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after typing." })), maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })), pressEnter: Type.Optional(Type.Boolean()), @@ -1595,12 +1596,13 @@ Usage rules: name: "chrome_fill", label: "Chrome Fill", description: - "Set the full value of a text input, textarea, or contenteditable element using Chrome click/select/delete/type input. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.", + "Set the full value of a text input, textarea, or contenteditable using Chrome click/select/delete/type input. Contenteditables use one native text insertion; perCharacter=true retains individual keydown events. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.", promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.", parameters: Type.Object({ text: Type.String(), uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })), 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." })), includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })), @@ -1636,7 +1638,7 @@ Usage rules: ctrlKey: Type.Optional(Type.Boolean()), altKey: Type.Optional(Type.Boolean()), metaKey: Type.Optional(Type.Boolean()), - }, { description: "Modifier keys to hold while pressing the key (chord)." })), + }, { description: "Modifier keys to hold while pressing the key. Shift alone types the shifted US-layout character (a → A, 1 → !); Ctrl/Meta/Alt chords do not insert literal text." })), includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the keypress." })), maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })), targetId: Type.Optional(Type.String()), diff --git a/package-lock.json b/package-lock.json index 1e05f8e..f7edadd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-chrome", - "version": "0.15.48", + "version": "0.15.49", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-chrome", - "version": "0.15.48", + "version": "0.15.49", "license": "MIT", "peerDependencies": { "@earendil-works/pi-coding-agent": "*", diff --git a/package.json b/package.json index c1f3b84..0a705bb 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "pi-chrome", - "version": "0.15.48", + "version": "0.15.49", "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", + "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", "version": "node scripts/sync-manifest-version.js", "prepublishOnly": "node scripts/sync-manifest-version.js" }, diff --git a/test-suite/README.md b/test-suite/README.md index 59d2388..c7321a1 100644 --- a/test-suite/README.md +++ b/test-suite/README.md @@ -177,6 +177,15 @@ The dashboard renders this from `manifest.json`. In brief: 41. explicit tab lifecycle 42. strict CSP eval/snapshot via CDP (regression guard for the CSP bypass) 43. hard background: inactive-tab visibility and trusted input +44. input reliability: native rich-editor insertion, full replacement, per-character override, Shift chords, single Enter, and file upload + +### Input regressions (16, 21, 31, 44) + +Challenge 44 exercises `chrome_type`/`chrome_fill` bulk insertion into contenteditables, including long Unicode text and multiple paragraphs. It also checks `perCharacter:true`, Shift-only printable keys, one Enter per `pressEnter`, and real file contents. Read `window.__inputFixture.caption` and follow its manifest recipe. Use `domFallback:false` to ensure failures are not hidden by synthetic events. + +Challenge 16 intentionally requests `perCharacter:true`: its grader requires a caret update for each keystroke. Challenge 21 focuses its field before sending Shift+a and grades after Shift release, not during the earlier input event. Challenge 31 retains standalone upload coverage. + +`npm test` also fault-injects zero/missing/rejected upload node IDs, attachment/cleanup failures, stale UIDs, insertion failures, shortcut suppression, and authorization/option forwarding. These mocked tests cannot prove `isTrusted` or real browser selection behavior; run the browser challenges too. ### Hard-background regression (43) diff --git a/test-suite/challenges/21-keyboard-modifiers.html b/test-suite/challenges/21-keyboard-modifiers.html index 870bef6..d18d484 100644 --- a/test-suite/challenges/21-keyboard-modifiers.html +++ b/test-suite/challenges/21-keyboard-modifiers.html @@ -30,9 +30,12 @@ for (const name of ["keydown","keypress","keyup","input"]) { }); } -t.addEventListener("input", () => { - if (t.value !== "A") return; +// input fires before keyup. Grade only when Shift is released so a valid chord +// cannot fail merely because its release events have not happened yet. +t.addEventListener("keyup", (event) => { + if (event.key !== "Shift") return; const bad = []; + if (t.value !== "A") bad.push(`value=${JSON.stringify(t.value)} (need 'A')`); const downShift = log.find(e => e.name === "keydown" && e.key === "Shift"); const downA = log.find(e => e.name === "keydown" && e.code === "KeyA"); const upShift = log.find(e => e.name === "keyup" && e.key === "Shift"); diff --git a/test-suite/challenges/44-input-reliability.html b/test-suite/challenges/44-input-reliability.html new file mode 100644 index 0000000..7576376 --- /dev/null +++ b/test-suite/challenges/44-input-reliability.html @@ -0,0 +1,105 @@ + + +44 input reliability + + + + +
+

Upload and text-input reliability

+

Read window.__inputFixture.caption. Type it into the empty editor, + then fill the old multi-paragraph editor with the same text. Leave + perCharacter off for both. Native text insertion must preserve + Unicode, newlines, and literal markup without hundreds of key events.

+
Exact caption (manual baseline: copy and paste into both editors)
+ +
+ +
OLD first paragraph
OLD second paragraph
OLD third paragraph
+ +
+ + +
+ + + +
+ + +

Node-ID conversion failure is fault-injected by the Node unit tests; this page + checks the real file contents and native upload event.

+ +
+ + diff --git a/test-suite/manifest.json b/test-suite/manifest.json index 4f72c85..cd6d34e 100644 --- a/test-suite/manifest.json +++ b/test-suite/manifest.json @@ -491,7 +491,7 @@ "id": "contenteditable-selection", "file": "challenges/16-contenteditable-selection.html", "category": "editing", - "goal": "Contenteditable typing advances selection/caret like user typing.", + "goal": "Contenteditable perCharacter typing advances selection/caret for each key (bulk insertion is covered by challenge 44).", "expected": { "synthetic": "CONDITIONAL", "trusted": "PASS", @@ -502,7 +502,8 @@ "tool": "chrome_type", "params": { "selector": "#ed", - "text": "hello" + "text": "hello", + "perCharacter": true } } ], @@ -687,6 +688,10 @@ "manual": "PASS" }, "recipe": [ + { + "tool": "chrome_click", + "params": { "selector": "#t", "domFallback": false } + }, { "tool": "chrome_key", "params": { @@ -1752,5 +1757,42 @@ "flakeRisk": "medium", "manualBaseline": "unverified", "gradeSource": "both" + }, + { + "id": "input-reliability", + "file": "challenges/44-input-reliability.html", + "category": "editing", + "difficulty": "L2", + "gate": "core", + "goal": "Upload a fixture, enter long Unicode rich-editor text, replace all old paragraphs, retain per-character typing, type Shift-only chords, and submit exactly once.", + "expected": { "synthetic": "FAIL", "trusted": "PASS", "manual": "PASS" }, + "prerequisites": [ + "Reload Pi and the updated Chrome companion. Snapshot before input; replace descriptive selectors with snapshot UIDs where available.", + "Substitute the chrome_evaluate caption result for $CAPTION and expand $PWD to the repository root." + ], + "recipe": [ + { "tool": "chrome_evaluate", "params": { "expression": "window.__inputFixture.caption" } }, + { "tool": "chrome_type", "params": { "selector": "#typed", "text": "$CAPTION", "includeSnapshot": true } }, + { "tool": "chrome_fill", "params": { "selector": "#filled", "text": "$CAPTION", "domFallback": false, "includeSnapshot": true } }, + { "tool": "chrome_type", "params": { "selector": "#keys", "text": "Ab!", "perCharacter": true, "includeSnapshot": true } }, + { "tool": "chrome_click", "params": { "selector": "#shift", "domFallback": false } }, + { "tool": "chrome_key", "params": { "key": "a", "modifiers": { "shiftKey": true } } }, + { "tool": "chrome_key", "params": { "key": "1", "modifiers": { "shiftKey": true } } }, + { "tool": "chrome_key", "params": { "key": "/", "modifiers": { "shiftKey": true }, "includeSnapshot": true } }, + { "tool": "chrome_type", "params": { "selector": "#once", "text": "done", "pressEnter": true, "includeSnapshot": true } }, + { "tool": "chrome_upload_file", "params": { "selector": "#file", "paths": ["$PWD/test-suite/fixtures/pi-chrome-upload.txt"] } }, + { "tool": "chrome_click", "params": { "selector": "#verify", "domFallback": false, "includeSnapshot": true } }, + { "tool": "chrome_evaluate", "params": { "expression": "JSON.stringify({v:window.__verdict,r:window.__reason,e:window.__events.slice(-20)})" } } + ], + "requires": { "cdp": true }, + "tags": ["upload", "contenteditable", "insertText", "keyboard", "unicode", "modifiers"], + "notes": [ + "Bulk insertion emits native editing events, not a keydown per character or a clipboard paste event. Per-character behavior remains covered by challenge 16 and the explicit keys editor here.", + "Manual baseline: paste the exact caption into both editors (select all old paragraphs first), type Ab!, release Shift between each Shift chord, type done then Enter once, and choose the fixture file.", + "The page checks actual file contents/native change. Node-ID conversion failure and cleanup are covered by fault-injected worker unit tests, not forced by the page." + ], + "flakeRisk": "low", + "manualBaseline": "unverified", + "gradeSource": "page" } ] diff --git a/test-suite/unit/background-policy.test.mjs b/test-suite/unit/background-policy.test.mjs index e20d0ab..e78fa49 100644 --- a/test-suite/unit/background-policy.test.mjs +++ b/test-suite/unit/background-policy.test.mjs @@ -119,6 +119,26 @@ test("every registered page tool, tab.new, and chrome_launch(url) use the centra assert.equal(h.writes[0][1].toString(), "test", "screenshot tool still writes decoded bytes"); }); +test("typing options and verification pass through real tool registrations without bypassing authorization", async () => { + const h = piHarness({ send: async () => ({ result: { input: "chrome", typing: "keys" }, snapshot: { url: "https://fixture.test" } }) }); + for (const name of ["chrome_type", "chrome_fill"]) { + assert.equal(h.tools.get(name).parameters.perCharacter.default, false); + const result = await h.tool(name, { uid: "el-1", text: "hello", perCharacter: true, includeSnapshot: true, domFallback: false }); + assert.equal(h.calls.at(-1).params.perCharacter, true); + assert.equal(h.calls.at(-1).params.includeSnapshot, true); + assert.equal(h.calls.at(-1).params.domFallback, false); + assert.equal(h.calls.at(-1).params.background, true); + assert.equal(result.details.result.result.typing, "keys"); + assert.equal(result.details.result.snapshot.url, "https://fixture.test"); + } + h.authorize(false); + const count = h.calls.length; + for (const [name, params] of [["chrome_type", { text: "x" }], ["chrome_fill", { text: "x" }], ["chrome_key", { key: "a" }], ["chrome_upload_file", { paths: ["/fixture.txt"] }]]) { + await assert.rejects(h.tool(name, params), /Chrome control locked/); + } + assert.equal(h.calls.length, count); +}); + test("activation is rejected before dispatch; existing on/off/toggle/status commands suffice", async () => { const h = piHarness(); await assert.rejects(h.tool("chrome_tab", { action: "activate", targetId: "2", background: false }), /background mode.*\/chrome background off/); diff --git a/test-suite/unit/input-reliability.test.mjs b/test-suite/unit/input-reliability.test.mjs new file mode 100644 index 0000000..1329ed7 --- /dev/null +++ b/test-suite/unit/input-reliability.test.mjs @@ -0,0 +1,401 @@ +// Exercise the shipped worker against mocked Chrome APIs and a separate page world. +// This checks command routing and failure cleanup, not browser isTrusted semantics. +// Live input/selection fidelity is covered by challenges 16, 21, 31, and 44. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +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 clone = (value) => JSON.parse(JSON.stringify(value)); + +function harness({ tag = "DIV", editable = true, initial = "", nodeId = 7 } = {}) { + const calls = [], selected = [], files = [], tabLookups = []; + let selectAll = false; + const element = { + tagName: tag, type: tag === "INPUT" ? "text" : undefined, + isContentEditable: editable, isConnected: true, textContent: initial, + scrollIntoView() {}, getBoundingClientRect: () => ({ left: 10, top: 10, width: 200, height: 60 }), + contains: (el) => el === element, + }; + const selection = { + removeAllRanges() { selectAll = false; }, + addRange(range) { selected.push(range.target); selectAll = range.target === element; }, + }; + const page = vm.createContext({ + document: { + activeElement: element, + querySelector: (selector) => selector === "#target" ? element : null, + createRange: () => ({ selectNodeContents(target) { this.target = target; } }), + }, + getSelection: () => selection, + location: { href: "https://fixture.test/" }, + __PI_CHROME_STATE__: { elements: { "el-1": element } }, + }); + page.window = page; + 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 }, + scripting: { executeScript: async ({ func, args = [] }) => { + calls.push({ method: "scripting.executeScript" }); + const fn = vm.runInContext(`(${func.toString()})`, page); + return [{ result: await fn(...args) }]; + } }, + }; + const worker = { + chrome, console, setTimeout, clearTimeout, setInterval: () => 0, + navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("no network in unit tests"); }, + }; + worker.self = worker; + vm.runInNewContext(workerSource, worker); + worker.sleep = async () => {}; + worker.getTabByParams = async (params) => { + tabLookups.push(clone(params)); + return { id: Number(params.targetId ?? 2), windowId: 1 }; + }; + worker.bringToFront = async () => {}; + worker.attachDebugger = async () => {}; + worker.cdpMoveTo = async () => {}; + worker.cdp = async (tabId, method, params = {}) => { + calls.push({ tabId, method, params: clone(params) }); + const error = h.failures.get(method); + if (error) throw new Error(error); + if (method === "Runtime.evaluate") { + try { + const result = vm.runInContext(params.expression, page); + if (result === element) return { result: { objectId: "upload-object" } }; + return { result: { value: clone(result) } }; + } catch (error) { + return { exceptionDetails: { text: error.message, exception: { description: error.message } } }; + } + } + if (method === "DOM.requestNode") return h.nodeResult; + if (method === "DOM.setFileInputFiles") files.push(...params.files); + if (method === "Input.insertText") element.textContent += params.text; + if (method === "Input.dispatchKeyEvent" && params.type === "keyDown") { + if (params.key === "Delete") { + // Triple-click selects a paragraph, not a whole multi-paragraph editor. + element.textContent = selectAll ? "" : element.textContent.replace(/[^\n]*$/, ""); + selectAll = false; + } else if (params.text) element.textContent += params.text; + } + return {}; + }; + worker.domFillFallback = async (_tabId, params) => { + calls.push({ method: "domFillFallback" }); + element.textContent = params.text; + return { input: "dom-fallback" }; + }; + 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 }), + commands: (method) => calls.filter((call) => call.method === method), + }; + return h; +} + +for (const [name, nodeResult] of [["node ID", { nodeId: 7 }], ["zero node ID", { nodeId: 0 }], ["missing node ID", {}], ["no response", undefined]]) { + test(`upload accepts ${name} and releases the remote object`, async () => { + const h = harness({ tag: "INPUT", editable: false }); + h.element.type = "file"; + 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" }; + assert.deepEqual(h.commands("DOM.setFileInputFiles").map((c) => c.params), [{ ...target, files: ["/tmp/a.txt", "/tmp/b.txt"] }]); + assert.deepEqual(h.files, ["/tmp/a.txt", "/tmp/b.txt"]); + assert.equal(result.input, "chrome"); + assert.deepEqual(clone(result.uploaded), [{ path: "/tmp/a.txt" }, { path: "/tmp/b.txt" }]); + assert.deepEqual(h.commands("Runtime.releaseObject").map((c) => c.params), [{ objectId: "upload-object" }]); + assert.equal(h.calls.at(-1).method, "Runtime.releaseObject"); + }); +} + +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"; + h.failures.set("DOM.requestNode", "node conversion unavailable"); + await h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }); + assert.deepEqual(h.commands("DOM.setFileInputFiles")[0].params, { objectId: "upload-object", files: ["/tmp/a.txt"] }); + h.calls.length = 0; + h.failures.set("DOM.setFileInputFiles", "attachment denied"); + h.failures.set("Runtime.releaseObject", "target closed during cleanup"); + 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"); +}); + +test("upload releases valid node-path references on failure and tolerates notification/cleanup failure", async () => { + const h = harness({ tag: "INPUT", editable: false }); + h.element.type = "file"; + h.failures.set("DOM.setFileInputFiles", "file inaccessible"); + 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"); + 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 () => { + const h = harness(); + await assert.rejects(h.call("upload", { selector: "#target", paths: ["/tmp/a.txt"] }), /Target must be /); + 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"; + h.element.type = "file"; + h.element.isConnected = false; + await assert.rejects(h.call("upload", { uid: "el-1", selector: "#target", paths: ["/tmp/a.txt"] }), /snapshot uid el-1 is stale/); + await assert.rejects(h.call("upload", { uid: "missing", selector: "#target", paths: ["/tmp/a.txt"] }), /snapshot uid missing is stale/); + assert.equal(h.commands("DOM.setFileInputFiles").length, 0); +}); + +const caption = "Long caption: café, 中文, 👩🏽‍💻. Two spaces.\nSecond paragraph! ".repeat(12); +for (const target of [{ selector: "#target" }, { uid: "el-1" }, {}]) { + test(`contenteditable typing uses one native insertText (${Object.keys(target)[0] ?? "already focused"})`, async () => { + 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.element.textContent, caption); + assert.equal(result.input, "chrome"); + assert.equal(result.length, caption.length); + assert.equal(result.typing, "insertText"); + }); +} + +for (const perCharacter of [false, true]) { + test(`contenteditable fill replaces every paragraph (perCharacter=${perCharacter})`, async () => { + const h = harness({ initial: "First paragraph\nSecond paragraph\nThird paragraph" }); + const text = perCharacter ? "Hello!" : caption; + const result = await h.call("fill", { uid: "el-1", text, perCharacter, domFallback: false }); + assert.equal(h.selected.length, 1); + assert.equal(h.selected[0], h.element); + assert.equal(h.element.textContent, text); + assert.equal(result.typing, perCharacter ? "keys" : "insertText"); + const deletion = h.calls.findIndex((c) => c.method === "Input.dispatchKeyEvent" && c.params.key === "Delete"); + const insertion = h.calls.findIndex((c) => c.method === "Input.insertText" || (c.method === "Input.dispatchKeyEvent" && c.params.text)); + assert.ok(deletion >= 0 && deletion < insertion); + assert.equal(h.commands("domFillFallback").length, 0); + }); +} + +test("fill refuses to select an unrelated focused editor", async () => { + const h = harness({ initial: "keep this" }); + h.page.document.activeElement = { isContentEditable: true, contains: () => false }; + await assert.rejects(h.call("fill", { selector: "#target", text: "replace", domFallback: false }), /requested contenteditable is not focused/); + assert.equal(h.selected.length, 0); + assert.equal(h.commands("Input.dispatchKeyEvent").length, 0, "no Delete in the wrong editor"); + assert.equal(h.element.textContent, "keep this"); +}); + +test("focused-editor inspection failures do not silently switch to keystrokes", async () => { + const h = harness(); + h.chrome.scripting.executeScript = async () => { throw new Error("inspection denied"); }; + await assert.rejects(h.call("type", { text: "hello" }), /inspection denied/); + assert.equal(h.commands("Input.insertText").length, 0); + assert.equal(h.commands("Input.dispatchKeyEvent").length, 0); +}); + +test("perCharacter preserves keydown-dependent editors; ordinary inputs/textareas retain key events", async () => { + for (const options of [{}, { tag: "INPUT", editable: false }, { tag: "TEXTAREA", editable: false }, { tag: "DIV", editable: false }]) { + const h = harness(options); + await h.call("type", { text: "Aa.!", ...(options.tag ? {} : { perCharacter: true }) }); + assert.equal(h.commands("Input.insertText").length, 0); + assert.equal(h.element.textContent, "Aa.!"); + assert.deepEqual(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.text).map((c) => c.params.text), ["A", "a", ".", "!"]); + } +}); + +test("empty typing is a no-op; empty fill still deletes the entire editor", async () => { + const h = harness({ initial: "first\nsecond" }); + await h.call("type", { text: "" }); + assert.equal(h.element.textContent, "first\nsecond"); + assert.equal(h.commands("Input.insertText").length, 0); + assert.equal(h.commands("Input.dispatchKeyEvent").length, 0); + await h.call("fill", { selector: "#target", text: "", domFallback: false }); + assert.equal(h.element.textContent, ""); + assert.equal(h.commands("Input.insertText").length, 0); +}); + +test("insertText failures propagate without silently retrying keystrokes; fill honors domFallback:false", async () => { + for (const action of ["type", "fill"]) { + const h = harness(); + h.failures.set("Input.insertText", "insertion failed"); + await assert.rejects(h.call(action, { selector: "#target", text: "hello", domFallback: false }), /insertion failed/); + assert.equal(h.commands("Input.insertText").length, 1); + assert.equal(h.commands("Input.dispatchKeyEvent").filter((c) => c.params.text).length, 0); + assert.equal(h.commands("domFillFallback").length, 0); + } + const h = harness(); + h.failures.set("Input.insertText", "insertion failed"); + const result = await h.call("fill", { selector: "#target", text: "hello" }); + assert.equal(result.input, "dom-fallback", "existing opt-out fallback remains available"); +}); + +test("stale typing/fill UIDs never insert text or delete the old selection", async () => { + for (const action of ["type", "fill"]) { + const h = harness(); + h.element.isConnected = false; + await assert.rejects(h.call(action, { uid: "el-1", text: "hello", domFallback: false }), /snapshot uid el-1 is stale/); + assert.equal(h.commands("Input.insertText").length, 0); + assert.equal(h.commands("Input.dispatchKeyEvent").length, 0); + } +}); + +for (const [key, text, code, vk] of [ + ["a", "A", "KeyA", 65], ["A", "A", "KeyA", 65], ["1", "!", "Digit1", 49], + ["!", "!", "Digit1", 49], ["2", "@", "Digit2", 50], [".", ">", "Period", 190], + ["/", "?", "Slash", 191], [" ", " ", "Space", 32], +]) { + test(`Shift+${key} emits ${text} with the correct physical key and text`, async () => { + const h = harness({ tag: "INPUT", editable: false }); + const result = await h.call("key", { key, modifiers: { shiftKey: true } }); + const events = h.commands("Input.dispatchKeyEvent").map((c) => c.params); + assert.deepEqual(events.map((e) => [e.type, e.key]), [["keyDown", "Shift"], ["keyDown", text], ["keyUp", text], ["keyUp", "Shift"]]); + assert.equal(events[1].text, text); + assert.equal(events[1].unmodifiedText, text); + assert.equal(events[1].code, code); + assert.equal(events[1].windowsVirtualKeyCode, vk); + assert.equal(events[1].modifiers, 8); + assert.equal(events.at(-1).modifiers, 0); + assert.equal(h.element.textContent, text); + assert.equal(result.key, text); + }); +} + +test("every US shifted digit/punctuation maps without changing its physical code", () => { + const { worker } = harness(); + const unshifted = "`1234567890-=[]\\;',./"; + const shifted = '~!@#$%^&*()_+{}|:"<>?'; + assert.equal(unshifted.length, shifted.length); + for (let i = 0; i < unshifted.length; i++) { + const base = worker.cdpKeyInfo(unshifted[i]); + const shift = worker.cdpKeyInfo(unshifted[i], true); + assert.equal(shift.text, shifted[i]); + assert.equal(shift.code, base.code); + assert.equal(shift.windowsVirtualKeyCode, base.windowsVirtualKeyCode); + } +}); + +test("Ctrl/Meta/Alt shortcuts never insert their literal character, even with Shift", async () => { + for (const modifier of ["ctrlKey", "metaKey", "altKey"]) { + for (const shiftKey of [false, true]) { + const h = harness(); + await h.call("key", { key: "a", modifiers: { [modifier]: true, shiftKey } }); + const down = h.commands("Input.dispatchKeyEvent").find((c) => c.params.code === "KeyA").params; + assert.equal(down.type, "rawKeyDown"); + assert.equal(down.text, ""); + assert.equal(down.unmodifiedText, ""); + assert.equal(h.element.textContent, ""); + } + } +}); + +test("named keys keep codes and Shift+Enter carries newline text", async () => { + for (const [key, code, text] of [["ArrowLeft", "ArrowLeft", ""], ["Tab", "Tab", "\t"], ["Enter", "Enter", "\r"], ["Escape", "Escape", ""]]) { + const h = harness(); + await h.call("key", { key, modifiers: { shiftKey: true } }); + const down = h.commands("Input.dispatchKeyEvent").find((c) => c.params.key === key).params; + assert.equal(down.code, code); + assert.equal(down.text, text); + } +}); + +test("challenge 21 waits for releases, accepts a complete trusted chord, and rejects missing text/synthetic input", () => { + const html = fs.readFileSync(new URL("../challenges/21-keyboard-modifiers.html", import.meta.url), "utf8"); + const script = html.match(/